OpenTelemetry Context Propagation: W3C Trace Context and Baggage
Context propagation is what turns spans recorded in separate processes into one trace. Service A puts identifiers into an outgoing request, service B reads them back out, and the span B creates becomes a child of A's span.
Without it you still get telemetry — just disconnected fragments, one per service, with no way to tell which of them belong to the same request.
Quick start
The default in every SDK is a composite of W3C Trace Context and Baggage, and it is usually what you want. Change it only when you interoperate with a system that speaks a different format.
export OTEL_PROPAGATORS=tracecontext,baggage
Instrumentation libraries do the injecting and extracting for you. If you use them for your HTTP servers and clients, propagation works with no code at all — the sections below matter when you have a protocol nothing instruments, or when traces break.
How it works
Two operations make up the whole mechanism:
- Inject — write the active context into an outgoing carrier: HTTP headers, message metadata, anything key-value shaped.
- Extract — read a carrier on the receiving side and rebuild the context, so spans created next attach to the right parent.
The context itself is not a global variable. In Go it rides in context.Context, in Python in a context variable, in Node.js in an async-local store. Losing it — by starting a goroutine without passing the context, or by hopping through a queue — is the usual cause of broken traces.
W3C trace context
traceparent is the standard header, and every OpenTelemetry SDK reads and writes it by default.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
^ ^ ^ ^
| trace-id (16 bytes) span-id (8 bytes) flags
version
| Field | Size | Meaning |
|---|---|---|
| version | 1 byte | 00 today |
| trace-id | 16 bytes | Identifies the whole trace. All-zero is invalid |
| parent-id | 8 bytes | The span id of the caller, which becomes the parent |
| flags | 1 byte | Bit 0 is the sampled flag: 01 sampled, 00 not |
The companion tracestate header carries vendor-specific data as a comma-separated list of key-value pairs, preserved across hops that do not understand it:
tracestate: ot=th:c,vendorname=opaqueValue
OpenTelemetry uses the ot key for its own entries, including the sampling threshold used by consistent probability sampling.
On the wire both headers travel with the request, added by the instrumentation rather than by your code:
GET /api/users/123 HTTP/1.1
Host: api.example.com
traceparent: 00-5b8efff798038103d269b633813fc60c-eee19b7ec3c1b174-01
tracestate: uptrace=t61rcWkgMzE
Propagators
A propagator is the code that reads and writes a specific format.
| Propagator | Format | Use when |
|---|---|---|
tracecontext | W3C traceparent / tracestate | Default. New systems |
baggage | W3C baggage header | Passing key-value context alongside the trace |
b3 / b3multi | Zipkin B3, single or multi-header | Interoperating with Zipkin or older Spring Cloud Sleuth |
jaeger | uber-trace-id | Interoperating with legacy Jaeger clients |
xray | AWS X-Ray | Interoperating with X-Ray |
Configure by environment variable, which takes a comma-separated list and builds the composite for you:
export OTEL_PROPAGATORS=tracecontext,baggage,b3
Or in code:
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
)
otel.SetTextMapPropagator(
propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
),
)
During a migration, list both formats. A composite propagator extracts using whichever format it finds and injects all of them, so services on either side of the migration stay connected.
Manual propagation
Needed for protocols no instrumentation library covers: custom RPC, WebSockets, message queues, batch jobs.
Injecting into an outgoing request
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
resp, err := http.DefaultClient.Do(req)
Extracting on the receiving side
The extracted context must be passed to the span you create, or the parent link is lost.
func handler(w http.ResponseWriter, r *http.Request) {
ctx := otel.GetTextMapPropagator().Extract(
r.Context(), propagation.HeaderCarrier(r.Header))
ctx, span := tracer.Start(ctx, "handle_request")
defer span.End()
doWork(ctx) // pass ctx down, or the chain breaks here
}
Message queues
Queues need the same treatment, with the carrier being message headers instead of HTTP headers. The difference is that producer and consumer are separated in time, so the consumer span is not a child in the usual sense — it starts a new trace segment linked to the producer.
// Producer: inject into message headers.
headers := map[string]string{}
otel.GetTextMapPropagator().Inject(ctx, propagation.MapCarrier(headers))
msg := &sarama.ProducerMessage{
Topic: "orders",
Value: sarama.StringEncoder(payload),
Headers: toRecordHeaders(headers),
}
// Consumer: extract from message headers.
ctx := otel.GetTextMapPropagator().Extract(
context.Background(), propagation.MapCarrier(fromRecordHeaders(msg.Headers)))
ctx, span := tracer.Start(ctx, "process orders",
trace.WithSpanKind(trace.SpanKindConsumer))
defer span.End()
Use context.Background() as the base on the consumer side rather than whatever context the consumer loop happens to hold. Otherwise every message processed by that loop attaches to the same long-lived parent span.
Baggage
Baggage carries key-value pairs alongside the trace, across every service the request touches. Where trace context answers "which trace is this", baggage answers "what else should everyone downstream know".
Typical uses: tenant id, deployment or experiment identifiers, a request's originating region. Anything that downstream services would otherwise have to look up.
import "go.opentelemetry.io/otel/baggage"
member, _ := baggage.NewMember("tenant.id", "acme-corp")
bag, _ := baggage.New(member)
ctx = baggage.ContextWithBaggage(ctx, bag)
// Downstream, in another service:
bag = baggage.FromContext(ctx)
tenant := bag.Member("tenant.id").Value()
Three constraints are worth knowing before you rely on it:
- Baggage is not automatically added to spans. It travels in the context; putting a value on a span is a separate, explicit step.
- It travels in a header on every request. Large baggage inflates every hop, and the W3C spec sets a limit of 8192 bytes.
- It crosses trust boundaries. Anything you put in baggage is visible to every downstream service and, if a request leaves your network, to whoever receives it. Never put credentials or personal data there.
Troubleshooting broken traces
Work outward from the wire: is the header sent, is it received, is it used.
Spans appear but are not connected
Check the header is actually on the wire:
curl -v http://your-service/endpoint 2>&1 | grep -i traceparent
If nothing appears, injection is not happening. If it appears but the receiving service starts a new trace, extraction is not happening or its result is being discarded.
The most common cause of the second case is extracting the context and then not using it:
// Extracted, then thrown away — the span becomes a new root.
ctx := otel.GetTextMapPropagator().Extract(r.Context(), carrier)
_, span := tracer.Start(r.Context(), "handler") // wrong context
// Correct: start from the extracted context.
ctx, span := tracer.Start(ctx, "handler")
No traceparent header at all
- The propagator was never set globally. Setting a tracer provider does not set a propagator; they are configured separately.
- The propagator was set after the client was constructed. Instrumented clients capture the propagator at creation. Configure OpenTelemetry before building HTTP clients.
- The request bypasses instrumentation. A raw
http.Clientdoes not inject anything, only an instrumented one does.
Confirm what is configured:
fmt.Printf("propagator: %T\n", otel.GetTextMapPropagator())
// Expect a composite or propagation.TraceContext, not a no-op.
Context lost in async work
Starting a goroutine, a thread, or a background task without passing the context detaches everything that follows:
// Lost: the goroutine gets a fresh, empty context.
go func() {
_, span := tracer.Start(context.Background(), "async_work")
span.End()
}()
// Kept: capture the context and pass it in.
go func(ctx context.Context) {
_, span := tracer.Start(ctx, "async_work")
span.End()
}(ctx)
In Python the equivalent is spawning a thread without copying the context; in Node.js it is escaping the async-local store, usually by way of an event emitter or a manual setTimeout.
Traces break at one specific boundary
When one hop consistently breaks while others work, suspect infrastructure between them:
- A proxy or gateway strips unknown headers. Some API gateways forward only an allowlist. Add
traceparent,tracestate, andbaggageto it. - The two services use different formats. One speaks W3C, the other B3. Configure a composite propagator on both.
- The hop is not HTTP. A queue or a cron trigger in the middle needs manual propagation.
Trace IDs differ between services
If each service reports a different trace id for what should be one request, extraction is not happening at all — every service is starting a root span. Check the receiving service's propagator configuration first; a service with no propagator silently ignores incoming context rather than failing.
Verifying propagation in tests
Assert on the wire format rather than on backend output, which is slower and hides the cause:
func TestPropagation(t *testing.T) {
ctx, span := tracer.Start(context.Background(), "test")
defer span.End()
headers := http.Header{}
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(headers))
if headers.Get("traceparent") == "" {
t.Fatal("traceparent not injected")
}
extracted := otel.GetTextMapPropagator().Extract(
context.Background(), propagation.HeaderCarrier(headers))
if trace.SpanContextFromContext(extracted).TraceID() != span.SpanContext().TraceID() {
t.Fatal("trace id did not survive a round trip")
}
}
What's next?
- OpenTelemetry distributed tracing — spans and the trace model
- OpenTelemetry sampling — how the sampled flag is decided
- OpenTelemetry semantic conventions — naming the attributes you attach
- Integration guides — instrumentation libraries that propagate automatically
- OpenTelemetry environment variables — configuring
OTEL_PROPAGATORS - OpenTelemetry troubleshooting — when spans never reach the backend at all