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

W3C traceparent, B3 multi-header, and B3 single-header Three header formats laid out. W3C traceparent packs version, a 32-character trace ID, a 16-character span ID and flags into one hyphen-separated header. B3 multi-header uses separate X-B3-TraceId, X-B3-SpanId, X-B3-ParentSpanId and X-B3-Sampled headers. B3 single-header packs the same fields into one b3 header separated by hyphens. The trace ID may be 16 or 32 characters in B3 but is always 32 in W3C. Same information, three encodings W3C traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 version · trace id (32 hex, always) · parent span id (16 hex) · flags B3 multi X-B3-TraceId: 4bf92f3577b34da6a3ce929d0e0e4736 X-B3-SpanId: 00f067aa0ba902b7 X-B3-ParentSpanId: 05e3ac9a4f6e3b90 X-B3-Sampled: 1 B3 single b3: 4bf92f...4736-00f067aa0ba902b7-1-05e3ac9a4f6e3b90 B3 permits a 16-hex (64-bit) trace ID; W3C does not. That mismatch is the only lossy part of the conversion. Field-by-field mapping between B3 and W3C Four mappings. B3 TraceId maps to the W3C trace ID field but may need left-padding from 16 to 32 hex characters. B3 SpanId maps directly to the parent span ID. B3 Sampled maps to the low bit of the flags byte. B3 ParentSpanId has no W3C equivalent and is dropped. W3C tracestate has no B3 equivalent and is lost in the other direction. What survives the conversion, and what does not B3 W3C X-B3-TraceId pad to 32 trace id X-B3-SpanId direct parent span id X-B3-Sampled 1 → 01 flags bit 0 X-B3-ParentSpanId dropped — implied by the hop (no equivalent) tracestate lost B3-ward tracestate

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

Why extraction must lead and injection must follow Four phases. Phase one, every service extracts both formats, which is safe in any order. Phase two, every service injects both formats, adding header bytes but breaking nothing. Phase three, verify that no service is still relying on B3 extraction by measuring which header each request arrived with. Phase four, remove B3 injection, which is the only step that can break a trace. Extraction is safe in any order · injection is not 1 · extract both any order, no risk roll out over weeks 2 · inject both +120 bytes/request still no risk 3 · measure which header was used? wait for zero B3 4 · drop B3 the only risky step reversible by config Reversing the order — dropping B3 injection before every consumer extracts W3C — breaks traces at exactly the hops nobody owns. Phase 3 is the one people skip, and it is the one that tells you whether phase 4 is safe.

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.format shows zero b3 for 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 on tracestate for sampling decisions, that loss changes behaviour.
  • Letting extraction order flip. If b3 precedes tracecontext in OTEL_PROPAGATORS, a request carrying both is read as B3 — usually harmless, but it makes the phase-3 measurement lie.

Related

↑ Back to Understanding W3C TraceContext Propagation