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 |
Description
The ZIO instrumentation's
TracingSupervisor.onSuspend()unconditionally callsContext.root().makeCurrent()when a fiber completes. WhenRuntime.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()returnsContext.root()(trace ID all zeros). Any subsequent instrumented Java API call (Kafkaproducer.send(), JDBC, HTTP client) creates an orphaned root span.Steps to Reproduce
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 afterunsafe.run()returns.Actual behavior
Context.current()isContext.root()(all zeros) afterunsafe.run()returns.Root cause
In
FiberContext.java:21-26: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 inonSuspend()instead of unconditionally writing root:Real-world impact
This breaks traces for anyone calling
unsafe.runfrom a thread that already has OTel context:fcDelay+ ZIO serde — a ZIO-based serializer called viaunsafe.runinsidedoobie.free.connection.delayon Doobie's transactor thread (which has context from the agent's Executor wrapping)unsafe.runinside a Java callback on an instrumented threadEnvironment