The most surprising thing about OpenTelemetry propagators is that they don’t actually do anything with the trace data itself; they’re purely about serializing and deserializing it for transport.
Let’s see this in action. Imagine a web server receiving an incoming request. This request might have trace context headers, like traceparent (W3C) or b3 (B3).
GET /api/v1/users/123 HTTP/1.1
Host: example.com
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
b3: 0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-1-05e3940f3870d2c5
When OpenTelemetry receives this, it needs to extract that context so it can continue the trace. This is where propagators come in. A W3CTraceContextPropagator looks for traceparent and tracestate, and a B3Propagator looks for b3 (in its various forms: single header, multiple headers, etc.).
Here’s a simplified conceptual view of what happens within the OpenTelemetry SDK (using Go as an example):
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
)
// Assume 'req' is an incoming HTTP request object
func handleRequest(req *http.Request) {
// Get the configured propagator. You'd typically set this up globally.
// For this example, let's assume we have both W3C and B3 configured.
propagator := otel.GetTextMapPropagator()
// Create a carrier from the incoming request headers.
// This carrier is what the propagator will read from and write to.
carrier := propagation.HeaderCarrier(req.Header)
// Inject the current span context into the carrier (for outgoing requests)
// or Extract the context from the carrier (for incoming requests).
// Here we are extracting for an incoming request.
ctx := propagator.Extract(req.Context(), carrier)
// Now 'ctx' contains the trace context. If a trace was found,
// a new span can be created that is linked to the parent span
// from the incoming request.
_, span := tracer.Start(ctx, "handleRequest")
defer span.End()
// ... rest of your request handling logic ...
}
The propagator.Extract(req.Context(), carrier) call is the magic. The propagator iterates through its supported formats, parses the relevant headers on the carrier, and reconstructs the SpanContext (which includes trace ID, span ID, sampling decision, etc.). If no valid trace context is found, Extract returns the original ctx without modification.
The mental model you need is that propagators are adapters. They know how to translate between the internal SpanContext representation and specific wire formats (HTTP headers, gRPC metadata, Kafka message headers, etc.). The otel.GetTextMapPropagator() function usually returns a composite propagator that knows about multiple formats.
For example, if you configure both W3C and B3, the default MultiPropagator will try to extract using W3C first. If it finds a valid traceparent header, it uses that and stops. If not, it tries B3. If it finds a valid b3 header, it uses that. This allows for interoperability where different services might use different trace context propagation standards.
When you send an outgoing request, you use propagator.Inject(ctx, carrier). This takes the SpanContext from the current ctx and writes the appropriate headers onto the carrier (e.g., req.Header.Set("traceparent", ...)).
The most subtle point is how traceparent and b3 can coexist. The MultiPropagator is designed to handle this. When injecting, it will write both traceparent and b3 headers if both are configured and the underlying context supports it. This is crucial for bridging systems that might only understand one format. If you have a W3C-only service sending to a B3-only service, you’d configure your propagator to Inject both, and the receiving B3 service would have its own B3Propagator to Extract it.
The next thing you’ll likely run into is configuring which propagators to use when setting up your SDK.