OTel keeps the active span in a runtime context; Python uses contextvars and Node uses AsyncLocalStorage to carry it across awaits.
Picture a relay race where the baton has a sticker with the team number. Every time a runner takes the baton, they look at the sticker to know which team they are on. Asyncio in Python and the event loop in Node are well-behaved relay courses: the baton goes from runner to runner without the sticker falling off. But if a runner steps off the course and hands the baton to a friend from the parking lot, the friend has no sticker to look at. They run their leg but they cannot tell anyone which team they belonged to. Context propagation is the sticker, the carrier is the relay course, and the escape routes are the parking lot.
Concept explanation~2 min read
Everything you need to truly understand this topic: intuition, mechanics, step by step explanation, code, formulas, and worked example. Click to expand.
Concept explanation~2 min read
Everything you need to truly understand this topic: intuition, mechanics, step by step explanation, code, formulas, and worked example. Click to expand.
Context propagation is the quiet plumbing that makes a trace look like a tree instead of a pile of unrelated spans. The mechanism is small but precise: an active context object carried by a runtime-specific carrier (Python contextvars, Node AsyncLocalStorage) that any child-span creation reads to find its parent.
The interesting parts are where the carrier ends. Async boundaries inside the runtime are handled. Thread boundaries, process boundaries, queue boundaries, and a handful of less obvious cases are not, and each has its own fix. This card walks through the carrier mechanics in each runtime, the canonical escape routes, and the verification practices that catch propagation gaps before they reach production.
The Context object as the in-process carrier
OTel's Context API is the cross-cutting interface. A Context is an immutable map from typed keys to values; the active span is one of those values, accessed via a standard key. When you create a child span, the SDK reads the active Context to set the parent. When you cross a boundary, the SDK serializes the Context into traceparent (and optionally tracestate, baggage).
Why immutable
Immutability means a Context can be safely shared across concurrent tasks without locking. To 'change' the context, you create a new Context with the change and set it active in your scope. The previous Context still exists, unchanged, in whatever scope was holding it.
What lives in a Context
- Active span (
SpanContext): the parent for any new child span. - Baggage: typed key-value extensions that propagate across services (e.g. user-tier, experiment-arm).
- Vendor extensions: per-vendor escape hatches; rarely used directly.
The carrier per runtime
The Context lives in a runtime-specific storage. Python uses contextvars. Node uses AsyncLocalStorage. Go passes Context explicitly through function signatures (no implicit carrier). Java uses thread-local plus async-aware libraries. The user-facing API is the same across languages; the internal carrier differs.
Situations where this technique stops working.
2–4 min · Everything important, quickly.
Real products, models, and research that use this idea.
- FastAPI plus OpenAI plus asyncpg in 2026 stitches into a single trace out of the box because all three are instrumented and share the contextvars carrier.
- Express plus OpenAI Node SDK with @opentelemetry/auto instrumentations node automatically wires AsyncLocalStorage across the request lifecycle.
What an interviewer would ask next. Try answering before peeking at the approach.
QWhat is the difference between baggage and the active span in the Context?
The active span is the parent for new child spans; baggage is application-defined key-value data (like user-tier or experiment id) that propagates alongside the span context across services.
Red flags & common mistakes
The phrases that signal junior thinking. Click to expand.
Red flags & common mistakes
The phrases that signal junior thinking. Click to expand.
Assuming context propagates everywhere; the moment you escape the runtime's carrier (raw threads, processes, unhooked timers) the active context is gone unless you copy it explicitly.
60 second bullets to scan on the way to the call.
Context object as the in-process carrier
Python contextvars and asyncio task semantics
Primary sources. Browse if you want the original framing.
Same topic, related formats. Practice these next.