Skip to main content
OpenTelemetry Java SDK and otel4s use different context management approaches. This guide shows how to bridge between them for seamless interoperability.

The Problem

OpenTelemetry Java SDK and otel4s rely on different context manipulation approaches:
  • Java SDK uses ThreadLocal variables
  • otel4s uses Local from Cats MTL
Here’s an example that demonstrates the issue:
import cats.effect.IO
import org.typelevel.otel4s.trace.Tracer
import io.opentelemetry.api.trace.{Span => JSpan}

def test(implicit tracer: Tracer[IO]): IO[Unit] =
  tracer.span("test").use { span => // start 'test' span using otel4s
    val jSpanContext = JSpan.current().getSpanContext // get span from ThreadLocal
    IO.println(s"Java ctx: $jSpanContext") >> 
    IO.println(s"Otel4s ctx: ${span.context}")
  }
Output:
Java ctx: {traceId=00000000000000000000000000000000, spanId=0000000000000000, ...}
Otel4s ctx: {traceId=318854a5bd6ac0dd7b0a926f89c97ecb, spanId=925ad3a126cec272, ...}
The contexts are out of sync because otel4s context isn’t visible to the Java SDK.

Terminology

NameDescription
Contextotel4s context that carries tracing information
Local[F, Context]The context carrier tool within the effect environment
Java SDKThe OpenTelemetry library for Java
JContextAlias for io.opentelemetry.context.Context
JSpanAlias for io.opentelemetry.api.trace.Span

Setup

To manually modify context, you need direct access to Local[F, Context]. Here’s how to construct it:
import cats.effect._
import cats.mtl.Local
import org.typelevel.otel4s.oteljava.context.Context
import org.typelevel.otel4s.oteljava.OtelJava

def program[F[_]: Async](
  otel4s: OtelJava[F]
)(implicit L: Local[F, Context]): F[Unit] = {
  val _ = (otel4s, L) // both OtelJava and Local[F, Context] are available
  Async[F].unit
}

val run: IO[Unit] =
  OtelJava.global[IO].flatMap { otel4s =>
    import otel4s.localContext
    program(otel4s)
  }

Using Java SDK Context with otel4s

When you need to run an effect with an explicit Java SDK context (e.g., materializing an effect inside a Pekko HTTP request handler), use this utility:
import cats.mtl.Local
import org.typelevel.otel4s.oteljava.context.Context
import io.opentelemetry.context.{Context => JContext}

def withJContext[F[_], A](
  ctx: JContext
)(fa: F[A])(
  implicit L: Local[F, Context]
): F[A] =
  Local[F, Context].scope(fa)(Context.wrap(ctx))
How it works:
  1. Context.wrap(ctx) - Creates otel4s context from JContext
  2. Local[F, Context].scope - Sets the given context as active for the effect fa

Example: Pekko HTTP Integration

import cats.effect.{Async, IO}
import cats.effect.std.Random
import cats.effect.syntax.temporal._
import cats.effect.unsafe.implicits.global
import cats.mtl.Local
import cats.syntax.all._
import org.apache.pekko.http.scaladsl.model.StatusCodes.OK
import org.apache.pekko.http.scaladsl.server.Directives._
import org.apache.pekko.http.scaladsl.server.Route
import org.typelevel.otel4s.Attribute
import org.typelevel.otel4s.trace.Tracer
import org.typelevel.otel4s.oteljava.context.Context
import io.opentelemetry.instrumentation.annotations.WithSpan
import io.opentelemetry.context.{Context => JContext}
import scala.concurrent.duration._

def route(implicit T: Tracer[IO], L: Local[IO, Context]): Route = 
  path("gen-random-name") {
    get {
      complete {
        OK -> generateRandomName(length = 10)
      }
    }
  }

@WithSpan("generate-random-name")
def generateRandomName(
  length: Int
)(implicit T: Tracer[IO], L: Local[IO, Context]): String =
  withJContext(JContext.current())(generate[IO](length)).unsafeRunSync()

def generate[F[_]: Async: Tracer](length: Int): F[String] =
  Tracer[F].span("generate", Attribute("length", length.toLong)).surround {
    for {
      random <- Random.scalaUtilRandom[F]
      delay  <- random.betweenInt(100, 2000)
      chars  <- random.nextAlphaNumeric.replicateA(length).delayBy(delay.millis)
    } yield chars.mkString
  }

def withJContext[F[_], A](
  ctx: JContext
)(fa: F[A])(
  implicit L: Local[F, Context]
): F[A] =
  Local[F, Context].scope(fa)(Context.wrap(ctx))
When you invoke the gen-random-name endpoint, the spans will be structured correctly:
> GET { http.method = GET, http.target = /gen-random-name, ... }
  > generate-random-name 
    > generate { length = 10 } 

Using otel4s Context with Java SDK

To interoperate with Java libraries that rely on Java SDK context, activate the context manually:
import cats.effect.Sync
import cats.mtl.Local
import cats.syntax.flatMap._
import org.typelevel.otel4s.oteljava.context.Context
import io.opentelemetry.context.{Context => JContext}

def useJContext[F[_]: Sync, A](
  use: JContext => A
)(implicit L: Local[F, Context]): F[A] = 
  Local[F, Context].ask.flatMap { ctx => // <1>
    Sync[F].delay {
      val jContext: JContext = ctx.underlying // <2>
      val scope = jContext.makeCurrent() // <3>
      try {
        use(jContext)
      } finally {
        scope.close()
      }
    }
  }
  1. Local[F, Context].ask - Get the current otel4s context
  2. ctx.underlying - Unwrap otel4s context to get JContext
  3. jContext.makeCurrent() - Activate JContext within the current thread
Depending on your use case, you may prefer Sync[F].interruptible or Sync[F].blocking instead of Sync[F].delay.

Example: Synchronized Contexts

import io.opentelemetry.api.trace.{Span => JSpan}

tracer.span("test").use { span => // start 'test' span using otel4s
  IO.println(s"Otel4s ctx: ${span.context}") >> useJContext[IO, Unit] { _ =>
    val jSpanContext = JSpan.current().getSpanContext
    println(s"Java ctx: $jSpanContext") 
  }
}
Output:
Otel4s ctx: {traceId=06f5d9112efbe711947ebbded1287a30, spanId=26ed80c398cc039f, ...}
Java ctx: {traceId=06f5d9112efbe711947ebbded1287a30, spanId=26ed80c398cc039f, ...}
Now the tracing information is synchronized, and you can use Java-instrumented libraries within the useJContext block.

Common Integration Patterns

Pattern 1: HTTP Client Calls

import org.http4s.client.Client
import org.typelevel.otel4s.trace.Tracer

def makeRequest[F[_]: Async: Tracer](
  client: Client[F],
  uri: String
): F[String] =
  Tracer[F].span("http-request").use { _ =>
    // Context automatically propagates to the HTTP client
    client.expect[String](uri)
  }

Pattern 2: Database Operations

import doobie._
import doobie.implicits._
import org.typelevel.otel4s.trace.Tracer

def queryUser[F[_]: Async: Tracer](
  userId: Long
)(implicit xa: Transactor[F]): F[Option[User]] =
  Tracer[F].span("db-query", Attribute("user.id", userId)).surround {
    sql"SELECT * FROM users WHERE id = $userId"
      .query[User]
      .option
      .transact(xa)
  }

Pattern 3: Message Processing

import cats.effect.IO
import org.typelevel.otel4s.trace.Tracer
import io.opentelemetry.context.{Context => JContext}

def processMessage(
  message: Message,
  jContext: JContext
)(implicit tracer: Tracer[IO], L: Local[IO, Context]): IO[Unit] =
  withJContext(jContext) {
    tracer.span("process-message").use { _ =>
      // Process message with proper context propagation
      IO.println(s"Processing: ${message.body}")
    }
  }

Complete Example

See PekkoHttpExample for a complete working example that demonstrates otel4s with OpenTelemetry Java SDK instrumented libraries.

Context API Reference

Context Operations

sealed trait Context {
  def underlying: JContext
  def get[A](key: Context.Key[A]): Option[A]
  def getOrElse[A](key: Context.Key[A], default: => A): A
  def updated[A](key: Context.Key[A], value: A): Context
}

Context Wrapping

// Wrap Java context
val ctx: Context = Context.wrap(jContext)

// Access underlying Java context
val jContext: JContext = ctx.underlying

// Root context
val root: Context = Context.root

Next Steps

Context Propagation

Learn about automatic context propagation

Java Agent

Use zero-code instrumentation

Build docs developers (and LLMs) love