B3 vs W3C traceparent Migration Guide
Configure every service to extract both B3 and W3C headers first, then to inject both, and only remove B3 injection once no service anywhere in the fleet still depends on extracting it — extraction is backwards-compatible, injection is not.
Context and when it matters
B3 is the Zipkin-era propagation format and it is still everywhere: Spring Cloud Sleuth defaults, older Istio installations, Envoy configurations copied from a 2019 blog post, and any service instrumented before the W3C specification stabilised. W3C TraceContext is the standard OpenTelemetry uses by default and the format every modern SDK understands without configuration.
A fleet running both, with no translation layer, produces exactly the failure that is hardest to diagnose: traces that break at one specific hop. The caller injects b3, the callee looks for traceparent, finds nothing, and starts a new root. Everything looks correctly instrumented on both sides, and every trace is cut in half. This is the most common cause of the fragmented-trace pattern described in diagnosing missing and broken traces.
The two formats
Three differences matter operationally. B3 allows a 64-bit trace ID where W3C requires 128 bits, so a legacy service emitting a short ID must have it left-padded on conversion. B3 carries the parent span ID as a separate field, which W3C does not — it is not needed, because the parent is implied by the hop. And B3’s sampled flag is 1/0 (or d for debug) where W3C uses the low bit of a hex flags byte.
Implementation
Extract both, inject both
The composite propagator is the whole migration. Extraction tries each propagator in order and takes the first that produces a valid context; injection writes every configured format.
# Environment-only configuration — no code change required.
# Order matters on extract: the first format that yields a context wins.
export OTEL_PROPAGATORS=tracecontext,baggage,b3multi,b3
// The same thing constructed explicitly, for a service that configures the SDK in code
OpenTelemetrySdk.builder()
.setPropagators(ContextPropagators.create(
TextMapPropagator.composite(
W3CTraceContextPropagator.getInstance(), // preferred on extract
W3CBaggagePropagator.getInstance(),
B3Propagator.injectingMultiHeaders(), // legacy callers
B3Propagator.injectingSingleHeader()))) // Envoy-style single header
.build();
During the transition every outbound request carries both header sets — roughly 120 extra bytes. That is the price of a migration nobody has to coordinate, and it is temporary.
Deal with 64-bit trace IDs
A service still emitting 64-bit B3 IDs produces a context that cannot round-trip through W3C without padding. The SDK’s B3 propagator handles this on extract, but any hand-rolled bridge must do it explicitly:
def b3_to_traceparent(headers: dict) -> str | None:
"""Convert B3 multi-headers to a W3C traceparent, padding short trace ids."""
trace_id = headers.get("X-B3-TraceId")
span_id = headers.get("X-B3-SpanId")
if not trace_id or not span_id:
return None
# B3 permits 16 hex chars (64-bit). W3C requires 32 — left-pad with zeros
# so the id stays stable and comparable across the boundary.
trace_id = trace_id.rjust(32, "0")
# B3 sampled is "1"/"0"/"d"; W3C flags is a hex byte where bit 0 is sampled.
sampled = headers.get("X-B3-Sampled", "0")
flags = "01" if sampled in ("1", "d", "true") else "00"
return f"00-{trace_id}-{span_id}-{flags}"
Padding is stable — the same short ID always produces the same padded ID — so a trace that crosses the boundary twice still joins up. What you cannot do is shorten on the way back: truncating a 128-bit ID to 64 bits loses information and produces collisions.
Update the proxies
Service meshes and reverse proxies must forward whichever headers they are not generating themselves. A proxy configured to allow-list headers will silently drop traceparent while happily forwarding x-b3-*, which produces a break that looks like an application bug.
# Envoy — emit W3C while continuing to accept and forward B3
tracing:
provider:
name: envoy.tracers.opentelemetry
typed_config:
"@type": type.googleapis.com/envoy.config.trace.v3.OpenTelemetryConfig
grpc_service:
envoy_grpc: { cluster_name: otel_collector }
service_name: edge-proxy
route_config:
request_headers_to_add: []
# Explicitly preserve both sets across the hop.
request_headers_to_remove: []
# nginx — forward both header families untouched
proxy_set_header traceparent $http_traceparent;
proxy_set_header tracestate $http_tracestate;
proxy_set_header X-B3-TraceId $http_x_b3_traceid;
proxy_set_header X-B3-SpanId $http_x_b3_spanid;
proxy_set_header X-B3-ParentSpanId $http_x_b3_parentspanid;
proxy_set_header X-B3-Sampled $http_x_b3_sampled;
The cutover order
Phase 3 deserves a concrete measurement rather than a survey of teams. Record which header supplied the context, as a bounded attribute, and let the data say when B3 traffic has actually stopped:
# Middleware: record the propagation format that produced our parent
if "traceparent" in request.headers:
span.set_attribute("propagation.format", "w3c")
elif "b3" in request.headers or "x-b3-traceid" in request.headers:
span.set_attribute("propagation.format", "b3")
else:
span.set_attribute("propagation.format", "none")
# Then simply watch it go to zero, per service
{ resource.service.name = "checkout-api" } | by(span.propagation.format) | count()
Why extraction is safe and injection is not
The asymmetry is worth stating plainly, because it is the entire reason this migration can be done without coordination.
Extraction is additive. A service that understands both formats behaves identically to one that understands only B3 when a B3 header arrives, and identically to a W3C-only service when a traceparent arrives. There is no request for which adding a second extractor changes the outcome, except the one where it previously failed and now succeeds. That means services can be updated in any order, over any timeframe, by any number of teams working independently — and each update strictly improves the situation.
Injection is subtractive at the end. Adding a second injected format is equally safe: receivers that only understand one of them ignore the other. But removing B3 injection changes what a downstream service receives, and if any service anywhere still relies on extracting B3, its traces break at that hop. Because the fleet’s dependency graph is rarely fully known — internal tools, batch jobs, a partner integration, a service owned by a team that reorganised twice — the only trustworthy signal is measured traffic, which is what phase 3 provides.
There is one more asymmetry that catches people: proxies and meshes are receivers too. An Envoy sidecar that reads B3 to make routing or sampling decisions counts as a service depending on B3 extraction, even though it produces no spans of its own. Include the mesh configuration in the phase-3 audit rather than treating it as infrastructure that follows automatically.
Verification
- No trace fragments at a known B3/W3C boundary. Send a request through the boundary with a fixed trace ID and confirm one trace, not two.
propagation.formatshows zerob3for a full week — including weekly batch jobs, which are the usual last holdout.- Trace IDs are 32 hex characters everywhere. A 16-character ID in storage means a padding step is missing somewhere.
- Header size is back to normal after phase 4, confirming dual injection is genuinely off.
Common pitfalls
- Removing B3 injection before phase 3. The break appears only for services you forgot about, which are by definition the ones with no owner watching.
- Assuming the mesh follows the application. Istio and Envoy have their own propagation configuration; a mesh still emitting only B3 undoes the application-level migration.
- Ignoring
tracestate. B3 has no equivalent, so vendor-specific state is silently lost when a hop converts B3 to W3C. If you rely ontracestatefor sampling decisions, that loss changes behaviour. - Letting extraction order flip. If
b3precedestracecontextinOTEL_PROPAGATORS, a request carrying both is read as B3 — usually harmless, but it makes the phase-3 measurement lie.
Related
- Understanding W3C TraceContext propagation — the format you are migrating to, in detail
- Debugging invalid traceparent headers — what to do when the converted header is rejected
- Fixing broken parent-child links across services — diagnosing the fragmentation this migration causes if done in the wrong order