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

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:

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:

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

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:

  1. Headers on the wire: is traceparent present on requests arriving at the downstream service? Log incoming headers or use a debug exporter.
  2. Proxies and gateways: some API gateways, CDNs, or custom proxies strip unknown headers. Allowlist traceparent, tracestate, and baggage.
  3. Propagator mismatch: one service sends B3 and the other expects W3C. Align OTEL_PROPAGATORS, or configure a composite propagator.
  4. Uninstrumented clients: a custom HTTP wrapper or an unsupported library makes calls without injecting context. Instrument it, or inject manually.
  5. Lost context in async code: callbacks or goroutines started without the parent context create orphan root spans.
  6. 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

References