Tracing Kotlin coroutines with OpenTelemetry
Kotlin coroutines need special care in context propagation to ensure that spans generate to the correct trace ID. Use the following guidance to help you determine which approach to apply in your Kotlin coroutines.
Considerations
OTel’s default context propagation uses thread-locals, while Kotlin coroutines suspend and resume across different worker threads. This means:
-
The thread-local context left on a worker thread may belong to a different coroutine.
-
@WithSpanon a suspend function is unreliable because of how the coroutine state machine works. This makes managing the context across state transitions difficult.
Use the approaches described below to address the resulting scenarios. See also PropagatedContext.java for the warning that surfaces when this happens.
Set up your application
Add the Kotlin OTel extension to your dependencies:
implementation("io.opentelemetry:opentelemetry-extension-kotlin:<version>")
For javaagent users, the opentelemetry-extension-kotlin artifact must be on the classpath. It is not bundled in the javaagent — add it explicitly as shown above.
Create spans for suspend functions
@WithSpan behavior on suspend functions is unreliable — avoid it. Suspend functions compile to state machines that take a Continuation parameter, which the javaagent’s bytecode instrumentation is not designed to handle correctly. Use the manual approach below instead.
Manual span with asContextElement()
Use the following function when you need explicit control or are not using the javaagent.
suspend fun process(): Result<T> {
val span = tracer.spanBuilder("process").startSpan()
// make the span current AND propagate it across suspension points
return withContext(span.asContextElement()) {
try {
delay(100) // suspends — context travels with the coroutine
doWork()
} finally {
span.end() // always ends regardless of suspension
}
}
}
asContextElement() installs the span as a ThreadContextElement in the CoroutineContext. This means that withCoroutineContext() inside DispatchedContinuation.run() will correctly set and restore the OTel thread-local on every resume, regardless of which worker thread the coroutine wakes up on.
Propagate context when launching child coroutines
By default, child coroutines inherit the parent’s CoroutineContext but not the OTel context unless it is explicitly installed. Always capture and pass the current OTel context at the launch site:
// Wrong — child may not inherit OTel context
launch(Dispatchers.Default) {
childWork()
}
// Correct — OTel context propagates into the child coroutine
launch(Dispatchers.Default + Context.current().asContextElement()) {
childWork()
}
Summary
|
Scenario |
Recommended Approach |
|---|---|
|
Suspend function |
|
|
Non-suspend function called from coroutine |
|
|
Launching child coroutines |
Pass |