How otel4s context propagation works

Use the how-to guides for setup and boundary-specific tasks:

This page explains how otel4s carries tracing context through effectful code and why some scopes need explicit re-entry.

otel4s relies on Local

The tracing context propagation logic revolves around cats.mtl.Local semantics:

trait Local[F[_], E] {
  def ask: F[E]
  def local[A](fa: F[A])(f: E => E): F[A]
}

It allows us to express and manage local modifications of the tracing context within effectful computations.

For tracing, the environment value is Context. otel4s reads the current Context when it decides the parent of a new span, and updates that Context while the span scope is active.

That means context propagation in otel4s follows the semantics of the effect carrier that provides Local[F, Context].

Local works out of the box with cats.data.Kleisli. On JVM, it also works with cats.effect.IOLocal, which is usually the practical default for IO applications.

The carrier decides how context moves

otel4s does not require one specific carrier. It needs Local[F, Context], and the carrier defines how local context is copied, inherited, or isolated.

Two common JVM choices are:

IOLocal is usually the practical default for applications. Kleisli is useful when your application already models context as part of the effect type.

Local changes stay on the branch where they happen

Starting a span changes the current tracing context only within the local branch that enters that span scope. Sibling fibers keep their own current context unless you explicitly propagate something else.

Here is the basic shape:

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')"]

This is why parent-child relationships in otel4s depend on the current local context at the point where a span is created. For the rules that determine parent selection, see Choosing parent spans and tracing scopes.

Java context and otel4s context are separate by default

On JVM, otel4s and OpenTelemetry Java use different context propagation mechanisms by default:

Without extra setup, those two views of the current span do not stay aligned automatically. For example, code inside an otel4s span can see that span through otel4s while io.opentelemetry.api.trace.Span.current() still returns the Java thread-local view.

For the setup that keeps them in sync, use Keep otel4s context in sync with OpenTelemetry Java. For boundary patterns that move between otel4s code and Java libraries, use Use otel4s with Java-instrumented libraries.

Limitations

Local works well for ordinary effectful code, but some data types split work into separate stages or branches. That matters for Resource and fs2.Stream.

The current encoding of cats.effect.Resource is incompatible with Local semantics. For example, you may want one lifecycle span to cover acquire, use, and release:

> resource
  > acquire
  > use
    > inner spans
  > release

That shape does not happen automatically just because the resource was created under a span. The use body or sub-stream often needs explicit re-entry into the captured span scope.

That is why otel4s exposes helpers such as trace, mapK(trace), and translate(trace) for these cases. For the detailed reasoning and examples, see Tracing Resource and fs2.Stream scopes and Trace Resource and fs2.Stream code.