Back in July, we encountered a tricky observability bug while running an asynchronous compute pipeline inside AWS Lambda processing data from Amazon S3.
We had instrumented our workload with OpenTelemetry (OTel) to gain granular insights into execution bottlenecks, network latency, and compute time. But when we opened our tracing dashboard, instead of a clean, cohesive flamegraph showing our execution flow, we were greeted with total chaos: span explosion, orphaned traces, and completely disconnected parent-child spans.
Here is what went wrong, why our concurrency model broke OpenTelemetry’s default behavior, and how we solved it using distributed tracing context propagation.
1. Context & Architecture
Our serverless pipeline processes large objects retrieved from S3:
- Lambda Invocation: An S3 object notification triggers the Lambda handler on the main runtime thread.
- Concurrent Compute: Because each S3 payload contains hundreds of independent tasks, the handler dispatches these compute tasks across a pool of background worker threads to leverage multi-core Lambda compute efficiently.
- Task Completion & Sinks: Each worker performs its computation, writes results, and signals completion.
|
|
To monitor performance, the Lambda handler initiated a root trace (lambda-s3-compute), and each worker task was supposed to create a child span (process-task) linked directly to that parent.
2. The Symptoms: The “Broken Trace” Mystery
When inspecting traces in our telemetry backend (Jaeger / Honeycomb), two bizarre problems showed up:
Issue A: Disconnected Orphan Traces
Instead of seeing one single trace ID containing the root handler span and multiple nested child spans, we saw dozens of separate root traces:
|
|
Issue B: Span Explosion & Redundant Telemetry
Because child tasks were not tethered to their parent, tasks that spawned sub-tasks or HTTP requests generated runaway spans with distinct IDs. Telemetry ingestion costs spiked, and searching for the trace of a single S3 file execution was virtually impossible because none of the spans shared a common trace context.
3. The Investigation & Root Cause
Where was the context getting lost?
In the main Lambda handler, the span was active:
|
|
Inside the worker threads, we were starting child spans like this:
|
|
The “Aha!” Moment: Thread Boundaries Need Context Propagation
In the Java OpenTelemetry SDK, the default context storage is thread-local. This is an implementation detail of Java context storage; other language SDKs use their own context-local mechanisms.
- When
parentSpan.makeCurrent()is called, the context is made current on the main thread for the scope in which it is active. - When tasks are submitted to a manually managed
ExecutorService, the worker thread does not automatically inherit the parent thread’s context. - When
tracer.spanBuilder("process-task").startSpan()executes inside the worker thread,Context.current()does not contain the parent span. The span builder therefore creates a new root span:“There is no active span on this thread. I must create a brand-new Trace ID and treat this as a brand-new Root Span!”
This explained why the worker spans were severed from the parent and appeared under unrelated trace IDs.
4. The Fix: Explicit Context Propagation Across Threads
To fix this, we applied the fundamental rule of distributed tracing: When crossing an execution boundary (network or thread), you must explicitly propagate the Context.
The Solution: Wrapping Runnables with OTel Context
OpenTelemetry provides built-in utilities to capture the active context from the parent thread and attach it to the worker execution.
Before (Broken):
|
|
After (Fixed with Context.current().wrap()):
|
|
Alternative: Using OpenTelemetry Context in Custom Handlers
If you’re passing state objects between threads, you can also pass the parent Context explicitly:
|
|
5. The Outcome
Once we deployed the fix:
- Unified Traces: Every S3 invocation produced exactly one Trace ID.
- Proper Tree Hierarchy: All worker threads appeared as neat, concurrent parallel child bars under the main handler span in the flamegraph.
- Accurate Bottleneck Detection: We could instantly identify which specific S3 task worker was bottlenecked on I/O vs. compute.
- Clean Telemetry Bills: Zero runaway root spans or detached noise.
6. Key Takeaways
- Do Not Assume Context Crosses Thread Pools: With manually managed Java executors, do not assume the current OpenTelemetry context will travel with a task unless instrumentation or explicit propagation is in place.
- Use
Context.current().wrap(): OpenTelemetry’sContext.wrap()is one straightforward way to capture and restore context around aRunnableorCallable. Instrumented executors can also perform this propagation for you. - Always End Spans in
finallyBlocks: Ensurespan.end()is guaranteed to execute, even on unhandled exceptions, to prevent memory leaks and dangling spans. - Inspect Flamegraphs Early: Don’t just test if telemetry is arriving; verify that the Trace ID and Parent Span ID relationships are mathematically connected.