Skip to content

ZIO instrumentation: Runtime.default.unsafe.run() permanently destroys caller thread's OTel context #16502

@BrianHotopp

Description

@BrianHotopp

Description

The ZIO instrumentation's TracingSupervisor.onSuspend() unconditionally calls Context.root().makeCurrent() when a fiber completes. When Runtime.default.unsafe.run() executes a fiber synchronously on the calling thread, this wipes the calling thread's pre-existing OTel context permanently.

After unsafe.run() returns, Context.current() returns Context.root() (trace ID all zeros). Any subsequent instrumented Java API call (Kafka producer.send(), JDBC, HTTP client) creates an orphaned root span.

Steps to Reproduce

val span = tracer.spanBuilder("parent").startSpan()
val scope = span.makeCurrent()

// Context.current() has valid trace ID here

Unsafe.unsafe { implicit u =>
  Runtime.default.unsafe.run(ZIO.succeed("hello"))(Trace.empty, u)
}

// Context.current() is now Context.root() — trace ID is 00000000000000000000000000000000

Minimal standalone reproduction: https://github.com/BrianHotopp/otel-zio-unsafe-run-bug

Failing test added to the existing test suite: BrianHotopp@1786a327

Expected behavior

The calling thread's Context.current() should be unchanged after unsafe.run() returns.

Actual behavior

Context.current() is Context.root() (all zeros) after unsafe.run() returns.

Root cause

In FiberContext.java:21-26:

public void onSuspend() {
    this.context = Context.current();
    Context.root().makeCurrent();  // <-- unconditionally wipes ThreadLocal
}

This is correct for scheduler-managed fibers (prevents context leakage between fibers sharing a thread), but unsafe.run() runs the fiber synchronously on the caller's thread — not a scheduler worker. The caller's pre-existing context is destroyed as collateral.

unsafe.fork() does NOT have this problem because the fiber runs on ZScheduler's worker threads.

Suggested fix

Save the previous context in onResume()/onStart() and restore it in onSuspend() instead of unconditionally writing root:

public void onSuspend() {
    this.context = Context.current();
    if (this.previousContext != null) {
        this.previousContext.makeCurrent();
    } else {
        Context.root().makeCurrent();
    }
}

Real-world impact

This breaks traces for anyone calling unsafe.run from a thread that already has OTel context:

  • Doobie fcDelay + ZIO serde — a ZIO-based serializer called via unsafe.run inside doobie.free.connection.delay on Doobie's transactor thread (which has context from the agent's Executor wrapping)
  • Java callback interop — using unsafe.run inside a Java callback on an instrumented thread
  • cats-effect interop — any bridge that runs ZIO effects synchronously on a non-ZIO thread

Environment

Component Version
opentelemetry-java-instrumentation 2.25.0
ZIO 2.1.14 (also reproduces with 2.0.0)
JDK 21.0.2

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions