The otel4s-oteljava-testkit provides in-memory implementations of metric and trace exporters. In-memory data can be used to test the structure of spans, the names of instruments, and many more.
The testkit is framework-agnostic, so it can be used with any test framework, such as weaver, munit, or scalatest.
Getting Started
// Add to build.sbt
libraryDependencies ++= Seq(
"org.typelevel" %% "otel4s-oteljava-testkit" % "0.15.0" % Test
)
IOLocalContextStorage
The otel4s-oteljava-testkit module depends on io.opentelemetry:opentelemetry-sdk-testing which sets the ContextStorageProvider to io.opentelemetry.sdk.testing.context.SettableContextStorageProvider. If you rely on IOLocalContextStorage in your tests, you will have the following error:
java.lang.IllegalStateException: IOLocalContextStorage is not configured for use as the ContextStorageProvider.
The current ContextStorage is: io.opentelemetry.sdk.testing.context.SettableContextStorageProvider$SettableContextStorage
To solve this, use the IOLocalTestContextStorage provided by the otel4s-oteljava-context-storage-testkit module.
// Add to build.sbt
libraryDependencies ++= Seq(
"org.typelevel" %% "otel4s-oteljava-context-storage-testkit" % "0.15.0" % Test
)
Parametrize your code to be able to override the LocalContextProvider:
import cats.effect.IO
import org.typelevel.otel4s.oteljava.context.LocalContextProvider
def program(implicit provider: LocalContextProvider[IO]): IO[Unit] = ???
And override it in your tests:
import cats.effect.IO
import org.typelevel.otel4s.oteljava.context.LocalContextProvider
import org.typelevel.otel4s.oteljava.testkit.context.IOLocalTestContextStorage
def test: IO[Unit] = {
implicit val provider: LocalContextProvider[IO] =
IOLocalTestContextStorage.localProvider[IO]
program
}
Testing Metrics
MetricData provides all information about the metric: name, instrumentation scope, telemetry resource, data points, associated attributes, collection time window, and so on. To simplify testing, define a minimized projection like TelemetryMetric.
Let’s test a program that increments a counter by one and sets a gauge’s value to 42:
import cats.effect.IO
import org.typelevel.otel4s.metrics.MeterProvider
import org.typelevel.otel4s.oteljava.testkit.OtelJavaTestkit
import org.typelevel.otel4s.oteljava.testkit.metrics.data.{Metric, MetricData}
// the program that we want to test
def program(meterProvider: MeterProvider[IO]): IO[Unit] =
for {
meter <- meterProvider.get("service")
counter <- meter.counter[Long]("service.counter").create
_ <- counter.inc()
gauge <- meter.gauge[Long]("service.gauge").create
_ <- gauge.record(42L)
} yield ()
// the test
def test: IO[Unit] =
OtelJavaTestkit.inMemory[IO]().use { testkit =>
// the list of expected metrics
val expected = List(
TelemetryMetric.SumLong("service.counter", List(1L)),
TelemetryMetric.GaugeLong("service.gauge", List(42L))
)
for {
// invoke the program
_ <- program(testkit.meterProvider)
// collect the metrics
metrics <- testkit.collectMetrics[Metric]
// verify the collected metrics
_ <- assertMetrics(metrics, expected)
} yield ()
}
// here you can use an assertion mechanism from your favorite testing framework
def assertMetrics(metrics: List[Metric], expected: List[TelemetryMetric]): IO[Unit] =
IO {
assert(metrics.sortBy(_.name).map(TelemetryMetric.fromMetric) == expected)
}
// a minimized representation of the MetricData to simplify testing
sealed trait TelemetryMetric
object TelemetryMetric {
case class SumLong(name: String, values: List[Long]) extends TelemetryMetric
case class SumDouble(name: String, values: List[Double]) extends TelemetryMetric
case class GaugeLong(name: String, values: List[Long]) extends TelemetryMetric
case class GaugeDouble(name: String, values: List[Double]) extends TelemetryMetric
case class Summary(name: String, values: List[Double]) extends TelemetryMetric
case class Histogram(name: String, values: List[Double]) extends TelemetryMetric
case class ExponentialHistogram(name: String, values: List[Double]) extends TelemetryMetric
def fromMetric(metric: Metric): TelemetryMetric =
metric.data match {
case MetricData.LongGauge(points) => GaugeLong(metric.name, points.map(_.value))
case MetricData.DoubleGauge(points) => GaugeDouble(metric.name, points.map(_.value))
case MetricData.LongSum(points) => SumLong(metric.name, points.map(_.value))
case MetricData.DoubleSum(points) => SumDouble(metric.name, points.map(_.value))
case MetricData.Summary(points) => Summary(metric.name, points.map(_.value.sum))
case MetricData.Histogram(points) => Histogram(metric.name, points.map(_.value.sum))
case MetricData.ExponentialHistogram(points) =>
ExponentialHistogram(metric.name, points.map(_.value.sum))
}
}
Testing Spans
SpanData provides all information about the span: name, instrumentation scope, telemetry resource, associated attributes, time window, and so on. To simplify testing, define a minimized projection like TelemetrySpan.
Let’s test the structure of created spans:
import cats.effect.IO
import io.opentelemetry.sdk.trace.data.SpanData
import org.typelevel.otel4s.oteljava.testkit.OtelJavaTestkit
import org.typelevel.otel4s.trace.TracerProvider
import scala.concurrent.duration._
// the program that we want to test
def program(tracerProvider: TracerProvider[IO]): IO[Unit] =
for {
tracer <- tracerProvider.get("service")
_ <- tracer.span("app.span").surround {
tracer.span("app.nested.1").surround(IO.sleep(200.millis)) >>
tracer.span("app.nested.2").surround(IO.sleep(300.millis))
}
} yield ()
// the test
def test: IO[Unit] =
OtelJavaTestkit.inMemory[IO]().use { testkit =>
// the list of expected spans
val expected = List(
SpanTree(
TelemetrySpan("app.span"),
List(
SpanTree(TelemetrySpan("app.nested.1"), Nil),
SpanTree(TelemetrySpan("app.nested.2"), Nil)
)
)
)
for {
// invoke the program
_ <- program(testkit.tracerProvider)
// collect the finished spans
spans <- testkit.finishedSpans
// verify the collected spans
_ <- assertSpans(spans, expected)
} yield ()
}
// here you can use an assertion mechanism from your favorite testing framework
def assertSpans(spans: List[SpanData], expected: List[SpanTree[TelemetrySpan]]): IO[Unit] =
IO {
val trees = SpanTree.fromSpans(spans)
assert(trees.map(_.map(data => TelemetrySpan(data.getName))) == expected)
}
// a minimized representation of the SpanData to simplify testing
case class TelemetrySpan(name: String)
// a tree-like representation of the spans
case class SpanTree[A](current: A, children: List[SpanTree[A]]) {
def map[B](f: A => B): SpanTree[B] = SpanTree(f(current), children.map(_.map(f)))
}
Mixing Java and Scala Instrumentation
In some cases, a program can rely on both Java instrumentation and Scala instrumentation. Testkit builders offer an option to use the same underlying Java OpenTelemetry exporter/reader.
import cats.effect.IO
import cats.effect.std.Dispatcher
import cats.FlatMap
import cats.mtl.Local
import cats.effect.kernel.Resource
import cats.syntax.flatMap.toFlatMapOps
import cats.syntax.functor.toFunctorOps
import io.opentelemetry.api.OpenTelemetry
import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator
import io.opentelemetry.context.{Context => JContext}
import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter
import org.typelevel.otel4s.context.LocalProvider
import org.typelevel.otel4s.oteljava.context.Context
import org.typelevel.otel4s.oteljava.testkit.context.IOLocalTestContextStorage
import org.typelevel.otel4s.oteljava.testkit.trace.TracesTestkit
import org.typelevel.otel4s.trace.SpanContext
import org.typelevel.otel4s.trace.Tracer
// application code
// a callback from a Java library which scopes and forwards
// the current span context to the Scala caller
def wrapJavaCallback[F[_]: FlatMap: Tracer](
callback: (Any, Option[SpanContext]) => F[Unit]
)(implicit
dispatcher: Dispatcher[F],
local: Local[F, Context]
): Any => Unit = (input: Any) =>
dispatcher.unsafeRunSync(Local[F, Context].scope(for {
context <- Tracer[F].currentSpanContext
_ <- callback(input, context)
} yield ())(Context.wrap(JContext.current())))
// test code
for {
inMemorySpanExporter <- IO.delay(InMemorySpanExporter.create()).toResource
// create your OpenTelemetry with the InMemorySpanExporter created above
(openTelemetry: OpenTelemetry) = null
local <- IOLocalTestContextStorage.localProvider[IO].local.toResource
tracesTestkit <- {
implicit val _localProvider = LocalProvider.fromLocal(local)
// inject InMemorySpanExporter here
TracesTestkit
.builder[IO]
.withInMemorySpanExporter(inMemorySpanExporter)
.addTextMapPropagators(W3CTraceContextPropagator.getInstance())
.build
}
tracer <- tracesTestkit.tracerProvider.get("my.service").toResource
program <- {
implicit val _local = local
implicit val _tracer = tracer
// at that point, both OpenTelemetry and Tracer rely on the same InMemorySpanExporter
program[IO](openTelemetry)
}
} yield (program, tracesTestkit)