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.

shell
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.

text
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             ^  ^                                ^                ^
             |  trace-id (16 bytes)              span-id (8 bytes) flags
             version
FieldSizeMeaning
version1 byte00 today
trace-id16 bytesIdentifies the whole trace. All-zero is invalid
parent-id8 bytesThe span id of the caller, which becomes the parent
flags1 byteBit 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:

text
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:

http
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.

PropagatorFormatUse when
tracecontextW3C traceparent / tracestateDefault. New systems
baggageW3C baggage headerPassing key-value context alongside the trace
b3 / b3multiZipkin B3, single or multi-headerInteroperating with Zipkin or older Spring Cloud Sleuth
jaegeruber-trace-idInteroperating with legacy Jaeger clients
xrayAWS X-RayInteroperating with X-Ray

Configure by environment variable, which takes a comma-separated list and builds the composite for you:

shell
export OTEL_PROPAGATORS=tracecontext,baggage,b3

Or in code:

go Go
import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/propagation"
)

otel.SetTextMapPropagator(
    propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{},
        propagation.Baggage{},
    ),
)
python Python
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from opentelemetry.baggage.propagation import W3CBaggagePropagator

set_global_textmap(
    CompositePropagator([
        TraceContextTextMapPropagator(),
        W3CBaggagePropagator(),
    ])
)
javascript Node.js
const { propagation } = require('@opentelemetry/api');
const {
  CompositePropagator,
  W3CTraceContextPropagator,
  W3CBaggagePropagator,
} = require('@opentelemetry/core');

propagation.setGlobalPropagator(
  new CompositePropagator({
    propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()],
  }),
);

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

go Go
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)
python Python
from opentelemetry.propagate import inject

headers = {}
inject(headers)

response = requests.get(url, headers=headers)
javascript Node.js
const { context, propagation } = require('@opentelemetry/api');

const headers = {};
propagation.inject(context.active(), headers);

const response = await fetch(url, { headers });

Extracting on the receiving side

The extracted context must be passed to the span you create, or the parent link is lost.

go Go
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
}
python Python
from opentelemetry.propagate import extract

@app.route("/api/endpoint")
def handler():
    ctx = extract(request.headers)

    with tracer.start_as_current_span("handle_request", context=ctx):
        do_work()
javascript Node.js
const { context, propagation, trace } = require('@opentelemetry/api');

function handler(req, res) {
  const ctx = propagation.extract(context.active(), req.headers);

  const span = tracer.startSpan('handle_request', undefined, ctx);
  context.with(trace.setSpan(ctx, span), () => {
    doWork();
    span.end();
  });
}

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.

go
// 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.

go Go
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()
python Python
from opentelemetry import baggage

ctx = baggage.set_baggage("tenant.id", "acme-corp")

# Downstream:
tenant = baggage.get_baggage("tenant.id", ctx)
javascript Node.js
const { propagation, context } = require('@opentelemetry/api');

const bag = propagation.createBaggage({
  'tenant.id': { value: 'acme-corp' },
});
const ctx = propagation.setBaggage(context.active(), bag);

// Downstream:
const tenant = propagation.getBaggage(context.active())?.getEntry('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:

shell
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:

go
// 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.Client does not inject anything, only an instrumented one does.

Confirm what is configured:

go
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:

go
// 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, and baggage to 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:

go
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?