Tracing context propagation
The tracing context propagation logic revolves around cats.mtl.Local semantics:
trait Local[F[_], E] {
def ask: F[E]
def local(fa: F[A])(f: E => E): F[A]
}
It allows us to express and manage local modifications of the tracing context within effectful computations.
Local
works out of the box with the cats.data.Kleisli.
It also works with cats.effect.IOLocal with a little help.
Here is an example of how the span propagation works:
graph LR A["Fiber A (no span)"] -->|fork| B_1["Fiber B (no span)"] -->|start span 'B' | B_2["Fiber B (span 'B')"] A --> A_2["Fiber A (no span)"] A --> |fork| C_1["Fiber C (no span)"] -->|start span 'C' | C_2["Fiber C (span 'C')"]
How to choose the context carrier
In the vast majority of cases, IOLocal
is the preferred and efficient way to propagate the context.
You can find both examples below and choose which one suits your requirements.
1. IOLocal
import cats.effect._
import cats.mtl.Local
import cats.syntax.flatMap._
import org.typelevel.otel4s.instances.local._ // brings Local derived from IOLocal
import org.typelevel.otel4s.oteljava.context.Context
import org.typelevel.otel4s.oteljava.OtelJava
import io.opentelemetry.api.GlobalOpenTelemetry
def createOtel4s[F[_]: Async](implicit L: Local[F, Context]): F[OtelJava[F]] =
Async[F].delay(GlobalOpenTelemetry.get).flatMap(OtelJava.fromJOpenTelemetry[F])
def program[F[_]: Async](otel4s: OtelJava[F]): F[Unit] = {
val _ = otel4s
Async[F].unit
}
val run: IO[Unit] =
IOLocal(Context.root).flatMap { implicit ioLocal: IOLocal[Context] =>
createOtel4s[IO].flatMap(otel4s => program(otel4s))
}
If you don't need direct access to the IOLocal
instance, there is also a shortcut OtelJava.fromJOpenTelemetry
:
import cats.effect._
import cats.syntax.flatMap._
import org.typelevel.otel4s.oteljava.OtelJava
import io.opentelemetry.api.GlobalOpenTelemetry
def createOtel4s[F[_]: Async: LiftIO]: F[OtelJava[F]] =
Async[F].delay(GlobalOpenTelemetry.get).flatMap(OtelJava.fromJOpenTelemetry[F])
def program[F[_]: Async](otel4s: OtelJava[F]): F[Unit] = {
val _ = otel4s
Async[F].unit
}
val run: IO[Unit] =
createOtel4s[IO].flatMap(otel4s => program(otel4s))
Of even shorter with OtelJava.global
:
import cats.effect._
import org.typelevel.otel4s.oteljava.OtelJava
def program[F[_]: Async](otel4s: OtelJava[F]): F[Unit] = {
val _ = otel4s
Async[F].unit
}
val run: IO[Unit] =
OtelJava.global[IO].flatMap(otel4s => program(otel4s))
2. Kleisli
import cats.effect._
import cats.syntax.flatMap._
import cats.data.Kleisli
import cats.mtl.Local
import org.typelevel.otel4s.oteljava.context.Context
import org.typelevel.otel4s.oteljava.OtelJava
import io.opentelemetry.api.GlobalOpenTelemetry
def createOtel4s[F[_]: Async](implicit L: Local[F, Context]): F[OtelJava[F]] =
Async[F].delay(GlobalOpenTelemetry.get).flatMap(OtelJava.fromJOpenTelemetry[F])
def program[F[_]: Async](otel4s: OtelJava[F]): F[Unit] = {
val _ = otel4s
Async[F].unit
}
val kleisli: Kleisli[IO, Context, Unit] =
createOtel4s[Kleisli[IO, Context, *]].flatMap(otel4s => program(otel4s))
val run: IO[Unit] = kleisli.run(Context.root)
Limitations
The current encoding of cats.effect.Resource is incompatible with Local
semantics.
For example, it's impossible to trace different stages of the resource (e.g. acquire, use, release) and
stay within the resource. A similar situation is with fs2.Stream
.
For example, you cannot get the following structure of the traces without allocation of the resource:
> resource
> acquire
> use
> inner spans
> release
To make it partially work, you need to trace each stage explicitly:
def acquire: F[Connection[F]] = ???
def release(c: Connection[F]): F[Unit] = ???
val resource: Resource[F, Connection[F]] =
Resource.make(Tracer[F].span("acquire-connection").surround(acquire)) { connection =>
Tracer[F].span("release").surround(release(connection))
}
def useConnection(c: Connection[F]): F[Unit] =
???
val io: F[Unit] = Tracer[F].span("resource").surround(
resource.use { connection =>
Tracer[F].span("use").surround(useConnection(connection))
}
)