DEV Community

Machine coding Master
Machine coding Master

Posted on

Stop Leaking Trace Context: How to Migrate OpenTelemetry to JDK 26 Scoped Values

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 ThreadLocal storage works fine with virtual threads, ignoring the heavy heap footprint and context drift when threads are unmounted and rescheduled.
  • Ignoring Context Leakage: Forgetting that ThreadLocal values persist unless explicitly removed, causing trace data to bleed into unrelated tasks on shared carrier threads.
  • Manual Propagation Mess: Manually passing Span objects 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 ContextStorage implementation backed by a static ScopedValue<Context>.
  • Enforce Immutability: Leverage the immutable nature of ScopedValue to prevent downstream child threads from accidentally mutating the parent's tracing context.
  • Leverage Structured Concurrency: Use StructuredTaskScope which 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
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • Zero Memory Overhead: ScopedValue is 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 StructuredTaskScope automatically inherit scoped trace contexts without manual configuration.

Top comments (0)