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.
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", "")
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.
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.
Related
- Context propagation and baggage — the parent guide: how context travels and what baggage adds.
- Propagating trace context across Celery tasks — the same headers, in a message rather than a request.
- Tracing gRPC services in Python — propagation through call metadata.
- Instrumenting aiohttp client requests — the outbound injection side.
- Sampling strategies for distributed tracing — what the sampled flag in these headers decides.
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'.