W3C Trace Context vs B3 Propagation

A trace breaks at exactly one hop, and the cause is nearly always that the two sides of that hop describe trace context differently. This page covers the two formats a Python service will meet, what each header carries, and the composite configuration that lets a fleet migrate without a flag day. It builds on context propagation and baggage, part of the distributed tracing and OpenTelemetry in Python section.

The same context, three header shapes One trace context expressed in three propagation formats, all carrying the same identifiers. W3C Trace Context uses a single traceparent header with four hyphen-separated fields: a two-hex version, a thirty-two hex trace identifier, a sixteen hex parent span identifier, and two hex trace flags whose lowest bit is the sampled flag; alongside it an optional tracestate header carries vendor key-value pairs. B3 single header packs the same identifiers into one b3 header as trace id, span id, sampled flag and optionally parent span id, separated by hyphens, with no equivalent of tracestate. B3 multi header spreads them across four separate headers: X-B3-TraceId, X-B3-SpanId, X-B3-Sampled and X-B3-ParentSpanId. The identifiers are byte-identical across all three, which is why interoperating is a matter of parsing rather than of translation — nothing is lost moving between them except tracestate, which only the first format has. one context, three shapes — the identifiers are identical W3C Trace Context traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 version · trace id (32 hex) · parent span id (16 hex) · flags — plus an optional tracestate header for vendor data B3 single header b3: 4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1 the same ids, one header, no tracestate equivalent B3 multi header X-B3-TraceId: 4bf92f3577b34da6a3ce929d0e0e4736 X-B3-SpanId: 00f0…02b7 X-B3-Sampled: 1
Nothing is lost moving between these except tracestate, which only the first format has — which is also why it is the one to standardise on.

Prerequisites

pip install "opentelemetry-sdk>=1.27.0,<2.0.0" \
            "opentelemetry-propagator-b3>=1.27.0,<2.0.0"
export OTEL_PROPAGATORS=tracecontext,baggage,b3multi

That variable is the whole configuration in most cases: the SDK builds a composite propagator from the listed names, and both extraction and injection use all of them.

Implementation

Step 1 — Find out what the other side actually sends. This is one observation and it saves an afternoon.

from fastapi import Request

@app.middleware("http")
async def log_propagation_headers(request: Request, call_next):
    interesting = {
        k: v for k, v in request.headers.items()
        if k.lower() in {"traceparent", "tracestate", "b3",
                         "x-b3-traceid", "x-b3-spanid", "x-b3-sampled"}
    }
    logging.getLogger("propagation").info("inbound context", extra={"headers": interesting})
    return await call_next(request)

Expected Output:

{"message": "inbound context", "headers": {"x-b3-traceid": "4bf92f35…", "x-b3-spanid": "00f067aa0ba902b7", "x-b3-sampled": "1"}}

An empty dict and a broken trace mean the context is not being sent — check the caller, or a proxy stripping headers. Headers present in a format you do not extract mean the context is arriving and being ignored, which is a configuration fix.

Step 2 — Configure a composite propagator. Environment variables cover the common cases; the programmatic form is there when you need an order the variable cannot express.

export OTEL_PROPAGATORS=tracecontext,baggage,b3multi,b3
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
from opentelemetry.propagators.b3 import B3MultiFormat, B3SingleFormat

set_global_textmap(CompositePropagator([
    TraceContextTextMapPropagator(),      # extraction is tried in order; first valid wins
    W3CBaggagePropagator(),
    B3MultiFormat(),
    B3SingleFormat(),
]))

Extraction tries each in order and takes the first that yields a valid context. Injection writes all of them, so an outbound request carries traceparent, b3, and the X-B3-* set at once. That redundancy is the point during a migration: whichever format the next service understands, it finds one.

Step 3 — Preserve tracestate. It carries vendor sampling decisions and multi-vendor identity. It is not yours to interpret, and dropping it degrades sampling silently.

# A custom propagator or middleware must pass this through unchanged.
carrier["tracestate"] = incoming.get("tracestate", "")
Where a mixed fleet breaks, and what the composite fixes A four-service request chain shown twice. In the first, services one, three and four speak W3C Trace Context while service two speaks only B3: the first hop works, the second arrives with a traceparent header that service two does not extract, so service two starts a new trace with no parent, and services three and four inherit that new trace. The result is two disconnected traces where there should be one, and the break is invisible from either end — each trace looks complete on its own. In the second, every service runs a composite propagator that extracts both formats and injects both: service two finds the b3 headers it understands, continues the trace, and injects both formats onward, so the chain stays intact end to end. The note added is that the fix only has to be deployed to the services at the boundary, not to the whole fleet at once, which is what makes the composite configuration a migration tool rather than a permanent state. one service in the chain speaks only B3 gateway W3C legacy-auth B3 only orders W3C inventory W3C traceparent arrives, is not extracted → a new trace starts here, with no parent and each of the two resulting traces looks complete on its own with a composite propagator at the boundary gateway injects both legacy-auth finds b3, continues orders one trace inventory one trace only the services at the boundary need the composite — which is what makes this a migration tool rather than a permanent configuration
Each of the two broken traces looks complete on its own, which is why this is usually found by someone asking why a request "stops" at a particular service.

Step 4 — Pick a target and set an end date. Composite propagation is a migration state, not a destination. Standardise on W3C Trace Context: it is the OpenTelemetry default, it is what meshes and managed services increasingly emit, and it is the only one of the three with a tracestate equivalent.

The failure no SDK configuration can fix A request travelling from a caller through an API gateway or service mesh to a receiving service. The caller injects traceparent, tracestate and the B3 headers correctly and the outbound request carries all of them. The gateway applies a header allowlist that names the headers it recognises — authorization, content type, a correlation id someone added years ago — and drops everything else, including every propagation header. The receiving service therefore extracts nothing, starts a new root span, and reports a broken trace, while the caller's own tests pass because the caller genuinely sent the headers. No propagator configuration on either side changes this: the data does not arrive. The diagnostic given is to log the raw inbound headers at the receiver, which distinguishes not-sent from sent-and-stripped in one observation, and the fix is a change to the gateway's allowlist rather than to any application. the headers were sent — and did not arrive caller traceparent ✓ b3 ✓ tracestate ✓ gateway with a header allowlist allow: authorization, content-type, x-corr-id everything else is dropped, silently receiver extracts nothing starts a new root span why this consumes a whole afternoon the caller's tests pass — it really does send the headers · the receiver's config is correct — it really does extract both formats log the raw inbound headers at the receiver: that single observation separates "not sent" from "sent and stripped" and the fix is a gateway allowlist entry, not a line of Python
Both applications are configured correctly and both teams can prove it. The header never reaches the second one.

Configuration options

Option Value Notes
OTEL_PROPAGATORS tracecontext,baggage the target state
tracecontext,baggage,b3multi,b3 during a migration
b3multi only when the fleet is entirely Zipkin-era
Extraction order first valid wins list the preferred format first
Injection all listed formats costs a few extra headers per request
tracestate pass through unchanged never drop or rewrite it
Proxy allowlists must include the headers a stripped header is indistinguishable from an unsent one

Verification

Send a request with each format and confirm the resulting span's parent.

curl -s localhost:8000/orders/1 \
  -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' > /dev/null

curl -s localhost:8000/orders/1 \
  -H 'X-B3-TraceId: 4bf92f3577b34da6a3ce929d0e0e4736' \
  -H 'X-B3-SpanId: 00f067aa0ba902b7' \
  -H 'X-B3-Sampled: 1' > /dev/null

Expected Output:

Span #0
    Name           : GET /orders/{id}
    Trace ID       : 4bf92f3577b34da6a3ce929d0e0e4736
    Parent ID      : 00f067aa0ba902b7

Span #1
    Name           : GET /orders/{id}
    Trace ID       : 4bf92f3577b34da6a3ce929d0e0e4736
    Parent ID      : 00f067aa0ba902b7

Both requests produce a span in the same trace with the same parent — that is extraction working for both formats. Then check the outbound side by having the handler call a downstream echo endpoint and asserting that traceparent and b3 are both present on the request it makes.

Common mistakes

The trace breaks at exactly one service

Error signature: two complete traces where there should be one, with the split always at the same hop. Root cause: that service, or a proxy in front of it, speaks a format the caller does not inject. Remediation: capture the inbound headers at the receiving service first — that distinguishes "not sent" from "sent and not understood" in one observation.

Sampling behaves differently after a hop

Error signature: downstream services sample at a different rate than configured, or a vendor's correlation is lost. Root cause: tracestate was dropped by a middleware that only forwarded traceparent. Remediation: propagate tracestate unchanged; it is opaque vendor state.

Headers are injected and never arrive

Error signature: the caller demonstrably sends traceparent; the receiver sees nothing. Root cause: a proxy, API gateway or service mesh with a header allowlist. Remediation: add the propagation headers to the allowlist. This is infrastructure configuration, and no amount of SDK configuration works around it.

Running the migration

Moving a fleet from B3 to W3C Trace Context is one of the least risky migrations available, because the composite propagator makes every intermediate state work. Four phases, each independently deployable.

Phase one: extract both, inject the incumbent. Add tracecontext to the extraction list while continuing to inject B3. Nothing changes for anyone — the services still speak B3 to each other — but every service can now understand a W3C header if it receives one. This phase is safe to roll out service by service in any order, because it only adds an ability.

Phase two: inject both. Now outbound requests carry traceparent and the B3 headers. Services still on phase one continue to read B3; services on phase two read whichever they see first. The cost is a few extra bytes per request. This is where the fleet spends most of the migration, and it is a stable state — there is no urgency to leave it.

Phase three: stop injecting B3. Once every service is on phase two, dropping B3 injection is safe, and it is worth verifying rather than assuming: a single service still on phase one will silently start a new trace on every request it receives, and the symptom is a trace that ends at that service rather than an error.

Phase four: stop extracting B3. Cosmetic, and worth doing so the configuration stops implying a compatibility requirement that no longer exists. Leave it until you are confident nothing outside your control — a partner integration, a vendor's agent, an old client — still sends B3.

Phase Extract Inject Safe to deploy per service?
0 b3 b3
1 tracecontext, b3 b3 yes, any order
2 tracecontext, b3 both yes, any order
3 tracecontext, b3 tracecontext only after every service is at 2
4 tracecontext tracecontext cosmetic

The things outside your control

Two categories of participant will not follow this plan, and both are worth identifying before phase three.

Infrastructure that generates its own context. A service mesh, an API gateway or a load balancer may create a trace at the edge and inject its own headers, in whichever format it is configured for. That is usually desirable — the trace then covers the ingress hop — and it means the format decision at the edge belongs to whoever operates that component rather than to the application teams. Check it explicitly rather than assuming it matches the fleet.

Vendor agents and SDKs. An APM agent attached to a service may inject its own headers, extract in its own precedence order, or both. Where an agent and the OpenTelemetry SDK are both active, the resulting behaviour depends on load order and is worth verifying with a real cross-service request rather than reasoning about.

Baggage travels alongside, and needs its own thought

baggage is a separate header carrying application-defined key-value pairs, propagated by the baggage propagator rather than by either trace-context format. It is genuinely useful — a tenant ID set at the edge and readable in every downstream service without threading it through every call signature — and it has three properties worth respecting.

It travels on every request, so its size is a per-request cost paid on every hop. It is not encrypted or signed, so nothing sensitive belongs in it and nothing security-relevant should be trusted from it. And it crosses trust boundaries if your service accepts requests from outside, which means an inbound baggage header is caller-controlled input and should be filtered rather than propagated blindly.

A short allowlist of keys, applied at the edge, addresses all three at once and costs one middleware.

Frequently Asked Questions

Which propagation format should a new service use?

W3C Trace Context. It is the specification OpenTelemetry defaults to, it is what meshes and managed services increasingly emit, and it carries tracestate for vendor-specific data that B3 has no equivalent for. B3 exists because Zipkin defined it years earlier and a great deal of infrastructure still speaks it, which is why interoperating matters more than choosing.

What is the difference between B3 single and multi header?

The same information in one header or four. B3 single packs trace id, span id, sampling flag and optional parent span id into one b3 header separated by hyphens. B3 multi uses X-B3-TraceId, X-B3-SpanId, X-B3-Sampled and X-B3-ParentSpanId. Some systems emit one and accept both; the B3MultiFormat and B3SingleFormat propagators in Python are separate classes, so extracting both means listing both.

What is in tracestate and can I drop it?

Vendor-specific key-value pairs travelling alongside traceparent — most commonly a sampling decision or a vendor's own trace identity in a multi-vendor setup. Dropping it does not break the trace, which is why it gets dropped, but it can silently change sampling behaviour downstream and lose the other vendor's correlation. Propagate it unchanged; it is not yours to interpret.

Can I run both propagators at once?

Yes, and during a migration you should. A composite propagator extracts from every format listed, taking the first that yields a valid context, and injects every format listed. The cost is a few extra headers on each outbound request, which is negligible next to a broken trace at a service boundary.

Why is the trace broken only across one specific service?

Almost always because that service, or the proxy in front of it, speaks the other format or strips unknown headers. Capture the raw inbound headers at the receiving service — that single observation identifies the cause faster than any amount of configuration review, because it distinguishes 'not sent' from 'sent and not understood'.