Propagating Context Across Thread Pools in Java
In Java the active OpenTelemetry Context lives in a ThreadLocal, so a task handed to an ExecutorService or CompletableFuture runs on a worker thread that has never seen it — restore continuity by wrapping the pool with Context.taskWrapping(executor) (or an individual task with Context.current().wrap(runnable)), then bind the span on the worker with a try-with-resources Scope.
Context and when it matters
The OpenTelemetry Java API keeps the current context propagation object in thread-local storage. Along a single synchronous call stack this is invisible and free: every method sees the same active span because they all run on one thread. The moment you hand work to a pool — executor.submit(...), CompletableFuture.supplyAsync(...), an @Async method, or a parallelStream() — the runnable executes on a different thread whose thread-local is empty. Any span started there has no parent and becomes a new root, breaking the distributed trace exactly where the concurrency boundary sits.
This is the Java-specific case of a general problem. The multi-threaded context guide covers the failure across runtimes; here the concern is the concrete Java toolkit — Context.taskWrapping, Context.wrap, and Scope — and how those pieces fit ExecutorService and CompletableFuture specifically.
Minimal working code
The whole fix is two moves: wrap the executor so the task carries the submit-time context, and make the span current on the worker with a closeable Scope.
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.Scope;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
// Wrap ONCE at construction. Every task submitted to this executor
// now runs with whatever Context was active when submit() was called.
ExecutorService pool =
Context.taskWrapping(Executors.newFixedThreadPool(8));
void handle(Tracer tracer) {
Span parent = tracer.spanBuilder("handle").startSpan();
try (Scope s = parent.makeCurrent()) { // bind parent to THIS thread
pool.submit(() -> {
// Runs on a worker thread, but the wrapped pool already
// restored the parent context, so this span parents correctly.
Span child = tracer.spanBuilder("async.work").startSpan();
try (Scope cs = child.makeCurrent()) {
doWork();
} finally {
child.end();
}
});
} finally {
parent.end();
}
}
Without the taskWrapping wrapper, async.work starts on an empty worker thread and lands in your backend as an orphan root with a fresh trace ID.
How wrapping actually works
Context.taskWrapping(executor) returns a thin decorator. On every submit/execute it calls Context.current() on the calling thread, captures it, and hands the pool a wrapped Runnable/Callable that reattaches that captured context on the worker before your code runs and detaches it afterward. You get correct propagation with no per-call boilerplate, which is why wrapping the pool is the preferred pattern when you own the pool.
For a pool you do not own — a shared framework executor, or a single opportunistic submission — wrap the task instead:
// Wrap a single Runnable with the context active right now.
Runnable traced = Context.current().wrap(() -> emitAudit(orderId));
sharedFrameworkPool.submit(traced); // pool itself is untouched
Context.current().wrap(runnable) binds the context at wrap time, so call it at the point you still hold the active span, not deep inside the pool.
Implementation: CompletableFuture chains
CompletableFuture is the sharpest edge because each stage may run on a different thread, and default stages run on the common ForkJoinPool. Capture the context once and reattach it inside each stage; supplying your own task-wrapped executor to every *Async call makes this automatic.
import java.util.concurrent.CompletableFuture;
CompletableFuture<Receipt> pipeline(Tracer tracer, String orderId) {
Span span = tracer.spanBuilder("checkout.pipeline").startSpan();
// Capture the context that holds `span` so async stages can restore it.
Context captured = Context.current().with(span);
// Passing a task-wrapped executor to supplyAsync means every stage
// scheduled on it inherits the captured context automatically.
return CompletableFuture
.supplyAsync(() -> {
try (Scope s = captured.makeCurrent()) { // reattach on this stage's thread
Span reserve = tracer.spanBuilder("reserve").startSpan();
try (Scope rs = reserve.makeCurrent()) {
return reserveInventory(orderId);
} finally {
reserve.end();
}
}
}, pool) // <-- the Context.taskWrapping executor from earlier
.thenApply(reserved -> {
try (Scope s = captured.makeCurrent()) { // each stage restores explicitly
return buildReceipt(reserved);
}
})
.whenComplete((r, err) -> span.end()); // end span when the chain settles
}
Two things carry the whole example: captured snapshots the context holding the span, and each stage opens its own Scope with try-with-resources so the binding is torn down when the stage finishes, even on exception.
Verification
Common pitfalls
- Forgetting to close the
Scope.makeCurrent()pushes onto the thread’s context stack; the returnedScopepops it. Because pool threads are reused, a leakedScopeleaves a stale span active, and the next, unrelated task on that thread parents to it. Always usetry (Scope s = span.makeCurrent()). - Wrapping the wrong thing. Wrapping the
Runnablewhen you meant to wrap the pool means every future submission you forget to wrap silently orphans. Wrapping the pool but then submitting through a second, unwrapped executor has the same effect. Decide which object is the propagation boundary and be consistent — the same discipline that keeps auto and manual instrumentation from colliding. parallelStream()and the common pool.parallelStream()dispatches toForkJoinPool.commonPool(), which no SDK wraps. Lambdas run with an empty context and orphan their spans. CaptureContext.current()before the stream andmakeCurrent()inside each operation, or keep traced work offparallelStream. This is the CPU-bound analogue of the reactive and async boundary losses seen in other stacks.
FAQ
Should I wrap the Runnable or the Executor?
Wrap the executor once with Context.taskWrapping when you own the pool, so every submitted task inherits whatever context was active at submit time. Wrap an individual Runnable with Context.current().wrap(runnable) only for one-off submissions to a pool you do not control.
Why does forgetting to close the Scope leak context?
makeCurrent() pushes the span onto the thread’s context stack and returns a Scope. Thread-pool threads are reused, so if you never close the Scope the next task on that thread inherits the stale span as its parent, producing wrong parent-child links. Always close it with try-with-resources.
Does parallelStream propagate the OpenTelemetry context?
No. parallelStream runs on the shared ForkJoinPool.commonPool(), which the SDK never wraps, so lambdas run with an empty context and orphan their spans. Capture Context.current() before the stream and call makeCurrent() inside each operation, or avoid parallelStream for traced work.
Related
- Instrumenting Spring Boot with OpenTelemetry — where
@AsyncandThreadPoolTaskExecutorneed this same wrapping. - Handling Async Boundaries in Node.js and Python — the equivalent problem in event-loop runtimes.
- OpenTelemetry SDK Setup for Backend Services — provider and exporter wiring these examples assume.