Skip to content

Commit 1786a32

Browse files
BrianHotoppclaude
andcommitted
Add failing test: unsafe.run destroys caller thread's OTel context
Runtime.default.unsafe.run() permanently wipes the calling thread's Context.current() because TracingSupervisor.onSuspend() unconditionally calls Context.root().makeCurrent() when the fiber completes. This affects any code that calls unsafe.run from a thread with existing OTel context — e.g. Doobie transactor threads (which have context from the agent's Executor wrapping), Java callbacks, or cats-effect bridges. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 23c56da commit 1786a32

2 files changed

Lines changed: 44 additions & 0 deletions

File tree

instrumentation/zio/zio-2.0/javaagent/src/test/scala/io/opentelemetry/javaagent/instrumentation/zio/v2_0/ZioRuntimeInstrumentationTest.scala

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import io.opentelemetry.instrumentation.testing.junit._
99
import io.opentelemetry.instrumentation.testing.util.TelemetryDataUtil.orderByRootSpanName
1010
import io.opentelemetry.javaagent.instrumentation.zio.v2_0.ZioTestFixtures._
1111
import io.opentelemetry.sdk.testing.assertj.{SpanDataAssert, TraceAssert}
12+
import org.assertj.core.api.Assertions.assertThat
1213
import org.junit.jupiter.api.extension.RegisterExtension
1314
import org.junit.jupiter.api.{Test, TestInstance}
1415

@@ -121,6 +122,19 @@ class ZioRuntimeInstrumentationTest {
121122
)
122123
}
123124

125+
@Test
126+
def unsafeRunShouldNotDestroyCallerThreadContext(): Unit = {
127+
val (traceIdBefore, traceIdAfter) = runUnsafeRunPreservesCallerContext()
128+
129+
assertThat(traceIdAfter)
130+
.describedAs(
131+
"Runtime.default.unsafe.run() should not destroy the calling thread's OTel context. " +
132+
"onSuspend() calls Context.root().makeCurrent() when the fiber completes, " +
133+
"which wipes pre-existing context on the calling thread."
134+
)
135+
.isEqualTo(traceIdBefore)
136+
}
137+
124138
private def assertTrace(f: TraceAssert => Any): Consumer[TraceAssert] =
125139
(t: TraceAssert) => f(t)
126140

instrumentation/zio/zio-2.0/javaagent/src/test/scala/io/opentelemetry/javaagent/instrumentation/zio/v2_0/ZioTestFixtures.scala

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,36 @@ object ZioTestFixtures {
122122
}
123123
}
124124

125+
/**
126+
* Demonstrates that Runtime.default.unsafe.run() destroys the calling thread's
127+
* OTel context. The onSuspend handler calls Context.root().makeCurrent() when
128+
* the fiber completes, which wipes any pre-existing context on the calling thread.
129+
*
130+
* Returns (traceIdBefore, traceIdAfter) so the test can assert they match.
131+
*/
132+
def runUnsafeRunPreservesCallerContext(): (String, String) = {
133+
val span = tracer.spanBuilder("caller_span").startSpan()
134+
val scope = span.makeCurrent()
135+
try {
136+
val traceIdBefore =
137+
io.opentelemetry.api.trace.Span.current().getSpanContext.getTraceId
138+
139+
Unsafe.unsafe { implicit unsafe =>
140+
zio.Runtime.default.unsafe
141+
.run(ZIO.succeed("hello"))(Trace.empty, unsafe)
142+
.getOrThrowFiberFailure()
143+
}
144+
145+
val traceIdAfter =
146+
io.opentelemetry.api.trace.Span.current().getSpanContext.getTraceId
147+
148+
span.end()
149+
(traceIdBefore, traceIdAfter)
150+
} finally {
151+
scope.close()
152+
}
153+
}
154+
125155
private val tracer: Tracer = GlobalOpenTelemetry.getTracer("test")
126156

127157
private def childSpan(opName: String)(op: UIO[Unit]): UIO[Unit] =

0 commit comments

Comments
 (0)