OpenTelemetry Context Propagation
A distributed trace is only useful if spans from different services end up in the same trace. Context propagation makes that happen: when service A calls service B, A injects the current trace context (trace ID, parent span ID, sampling decision) into the outgoing request, and B extracts it and creates its spans as children. Without propagation, each service produces disconnected fragments, and you lose the end-to-end view that makes distributed tracing worth having.
OpenTelemetry standardizes this through propagators and the W3C Trace Context format. Auto-instrumentation handles common HTTP and gRPC calls automatically. Broken traces usually come from asynchronous hops (queues, background jobs, thread pools), custom protocols, or proxies that strip headers.
TL;DR
- Context carries the active span (trace ID + span ID + flags) and baggage within a process.
- Propagators serialize context into carriers (HTTP headers, message headers) with inject and extract.
- The default format is W3C Trace Context:
traceparent(andtracestate) headers. B3 and Jaeger formats exist for legacy compatibility. - Baggage propagates arbitrary key-values (tenant, feature flag) across services. It's visible downstream, so never put secrets in it.
- For message queues, inject context into message headers on produce and extract it on consume, using links for batch processing.
- In async code, make sure context flows across threads, tasks, and callbacks (language-specific context APIs handle most cases).
Quick Example
The W3C traceparent header on an outgoing request:
Manually propagating context through Kafka (Python):
Instrumentation libraries for Kafka, RabbitMQ, SQS, and others do this automatically when available.
Core Concepts
Context Within a Process
OpenTelemetry keeps an implicit current context using each language's mechanism: contextvars in Python, AsyncLocalStorage in Node.js, ThreadLocal plus the Context API in Java, context.Context passed explicitly in Go. Starting a span with start_as_current_span sets it as current, so child spans and outgoing calls pick it up automatically.
In Go, context must be passed explicitly as the first argument (ctx context.Context). Forgetting to pass ctx is the most common cause of broken traces there.
Propagators: Inject and Extract
A propagator knows how to write context into a carrier and read it back:
inject(carrier): on outgoing calls, writes headers from the current context.extract(carrier): on incoming calls, reads headers into a context, which becomes the parent of the server span.
Configure propagators with OTEL_PROPAGATORS (default tracecontext,baggage). Add b3multi or jaeger when interoperating with older Zipkin- or Jaeger-instrumented services.
W3C Trace Context
A W3C standard supported by all major vendors, cloud load balancers, and service meshes:
traceparent: version, trace ID, parent span ID, and trace flags (the sampled bit).tracestate: vendor-specific key-values, passed along unchanged by others.
Because the sampling decision travels in the flags, downstream services using parent-based sampling make consistent decisions, and traces are either fully recorded or not at all. See OpenTelemetry sampling.
Baggage
Baggage is a separate header (baggage:) carrying key-value pairs across service boundaries, such as tenant.id, user.tier, or experiment.variant. Services can read it and copy it into span attributes or metric attributes (with care for cardinality). It doesn't automatically become span attributes; you (or a span processor) must add it. Baggage is sent to every downstream service, including third parties if you're not careful, so never include PII or secrets.
Asynchronous and Messaging Patterns
- Queues and streams: inject into message headers on produce; extract on consume. The consumer span can be a child of the producer span (one message, one handler) or reference it with a link (batch consumers processing many messages, each from a different trace).
- Background jobs: store the serialized context with the job payload and extract it when the worker runs.
- Thread pools and executors: wrap tasks with context-aware executors (Java's
Context.taskWrapping, and instrumented executors in the Java agent). - Scheduled or cron jobs: start a new root trace, and link to related traces if meaningful.
See message queues and Kafka consumer groups.
Debugging Broken Traces
Symptoms: a service's spans appear as separate traces, or traces stop at a certain hop. Check:
- Headers on the wire: is
traceparentpresent on requests arriving at the downstream service? Log incoming headers or use a debug exporter. - Proxies and gateways: some API gateways, CDNs, or custom proxies strip unknown headers. Allowlist
traceparent,tracestate, andbaggage. - Propagator mismatch: one service sends B3 and the other expects W3C. Align
OTEL_PROPAGATORS, or configure a composite propagator. - Uninstrumented clients: a custom HTTP wrapper or an unsupported library makes calls without injecting context. Instrument it, or inject manually.
- Lost context in async code: callbacks or goroutines started without the parent context create orphan root spans.
- Sampling inconsistencies: a downstream service ignoring the parent's sampled flag records fragments. Use parent-based samplers.
Best Practices
Standardize on W3C Trace Context
Use tracecontext,baggage everywhere, and add legacy formats only at the boundaries that need them. W3C is supported by cloud load balancers, service meshes, and every major vendor.
Propagate Across Every Hop, Including Queues
Most trace gaps are asynchronous boundaries. Treat message headers as a first-class carrier, and use links where fan-in makes parent-child relationships ambiguous.
Treat Incoming Context From Untrusted Clients Carefully
Public endpoints receive traceparent headers from browsers and third parties. Decide whether to trust them: you might start new traces at the edge and link to the external context, to prevent clients from forcing sampling or polluting traces.
Keep Baggage Small and Safe
Baggage adds bytes to every downstream request. Limit it to a few small, non-sensitive keys, and strip it at your trust boundary before calling external APIs.
Common Mistakes
Not Passing Context in Go
Creating New Root Spans in Consumers
Starting a consumer span without extracting message headers breaks the link between the producing request and the async processing, making it impossible to trace an order from API call to fulfillment.
Putting Sensitive Data in Baggage
baggage: user.email=jane@example.com travels to every downstream service and may end up in third-party logs. Use opaque IDs, or nothing.
FAQ
What's the difference between trace context and baggage?
Trace context (traceparent) identifies the trace and the parent span, so spans can be assembled into a tree. Baggage carries application-defined key-values alongside it, for use by downstream services. They're propagated by separate propagators and headers.
Do service meshes propagate traces automatically?
Meshes like Istio and Linkerd create proxy spans and can forward trace headers, but they can't connect an incoming request to the outgoing requests your application makes. The application must propagate context from inbound to outbound calls. Instrumentation libraries do this.
Should consumer spans be children of producer spans or links?
For one message handled by one consumer, a parent-child relationship produces a readable end-to-end trace. For batch processing, where one consumer span handles many messages from different traces, use span links to each message's context, since a span can have only one parent.
How do browsers participate in tracing?
The OpenTelemetry JavaScript web SDK can create spans for page loads and fetch/XHR calls and inject traceparent into requests to your backend. That requires CORS configuration allowing those headers. It connects frontend performance with backend traces.
Related Topics
- OpenTelemetry — The project overview
- OpenTelemetry Instrumentation — Spans, attributes, and SDK setup
- OpenTelemetry Sampling — How the sampled flag propagates
- Distributed Tracing — Traces across services
- Microservices — Where propagation matters most
- Message Queues — Async hops that need explicit propagation