Stop Leaking Trace Context: How to Migrate OpenTelemetry to JDK 26 Scoped Values
If you are still relying on traditional ThreadLocal storage for OpenTelemetry context propagation under JDK 26's virtual threads, you are sitting on a production time bomb. Millions of concurrent virtual threads will quickly turn your heap into a graveyard of leaked trace contexts and bloated memory overhead.
If you're prepping for interviews, I've been building javalld.com — real machine coding problems with full execution traces.
Why Most Developers Get This Wrong
-
Defaulting to ThreadLocal: Assuming the default OpenTelemetry
ThreadLocalstorage works fine with virtual threads, ignoring the heavy heap footprint and context drift when threads are unmounted and rescheduled. -
Ignoring Context Leakage: Forgetting that
ThreadLocalvalues persist unless explicitly removed, causing trace data to bleed into unrelated tasks on shared carrier threads. -
Manual Propagation Mess: Manually passing
Spanobjects down the call stack instead of leveraging JDK 26's native scoped value propagation.
The Right Way
The clean solution is to bind OpenTelemetry's ContextStorage directly to JEP 487 Scoped Values to enforce immutable, automatic, and thread-safe context propagation across virtual threads and structured concurrency boundaries.
-
Implement Custom ContextStorage: Create an OTel
ContextStorageimplementation backed by a staticScopedValue<Context>. -
Enforce Immutability: Leverage the immutable nature of
ScopedValueto prevent downstream child threads from accidentally mutating the parent's tracing context. -
Leverage Structured Concurrency: Use
StructuredTaskScopewhich automatically inherits the scoped trace context without manual boilerplate.
Show Me The Code
Here is how to run a span using JDK 26 ScopedValue for zero-leak, zero-overhead propagation:
public class ScopedTraceRunner {
private static final ScopedValue<Span> ACTIVE_SPAN = ScopedValue.newInstance();
public void execute(Span span, Runnable task) {
// Bind span immutably to the current scope
ScopedValue.where(ACTIVE_SPAN, span).run(() -> {
try (var scope = span.makeCurrent()) {
task.run();
} // Span scope closes cleanly here
});
}
}
Key Takeaways
-
Zero Memory Overhead:
ScopedValueis optimized for millions of virtual threads, avoiding the heavy thread-local map overhead. - Strict Scope Lifecycle: Contexts are automatically unbound when the execution block exits, completely eliminating trace leakage.
-
Native Structured Concurrency: Child threads spawned inside a
StructuredTaskScopeautomatically inherit scoped trace contexts without manual configuration.
Top comments (0)