SherlockLiu Logo SherlockLiu
Back to all posts
Engineering

Practical OpenTelemetry — Part 5: Context, Baggage, and Propagators

SL
Aug 17, 2026 10 min read
Practical OpenTelemetry — Part 5: Context, Baggage, and Propagators

The most important work in distributed observability happens in the moments between services. A request leaves one process with a span ID, hops through a load balancer, lands in another process — and unless something carries the context across that hop, the second service has no idea the first exists.

Chapter 5 of Practical OpenTelemetry covers that invisible glue: the Context API, Baggage, and propagators. It’s the least glamorous chapter in the book and, arguably, the one that determines whether your observability actually works.

The Context API: Transaction-Scoped State

The problem: operations in distributed systems happen across threads, coroutines, and services — but telemetry needs transactionally-scoped information that follows the operation wherever it goes. The pre-OTel answer was hand-rolled correlation IDs: add a transaction_id attribute, propagate it via custom request headers, decorate logs via Java’s MDC, then manually join everything in the backend. The book’s verdict: “not effective observability.” It works until someone forgets to propagate a header in one library, and then your traces quietly break.

OTel’s answer is the Context API — a standard way to manage key-value pairs related to the operation currently being handled. Implementation varies by language, using whatever implicit context mechanism the runtime provides:

  • Java: ThreadLocal storage
  • Python: Context Variables
  • JavaScript: Async Hooks

Two properties matter for correctness:

Context is immutable. Every modification returns a new instance containing the updated pairs plus all parent keys. In Java, Context.current().with(KEY, value) returns the new context, and makeCurrent() attaches it — returning a Scope that you must close (try-with-resources handles this). Leaked scopes produce subtly wrong telemetry: spans attributed to the wrong parent.

Async code needs explicit wiring. When work moves to another thread, the context doesn’t automatically follow. The API provides Context.current().wrap(...) for Runnables and Callables, or Context.taskWrapping(executor) to make a thread pool inherit context automatically. The Java agent can also auto-instrument common executors — but the book warns this isn’t always appropriate: blindly propagating context to fire-and-forget tasks can misrepresent causality (a theme Part 6 returns to).

Baggage: Your Custom Correlation Dimensions

Traces correlate operations within a transaction. But sometimes you want to correlate on properties the trace system doesn’t know about. Baggage is user-defined key-value pairs propagated across services alongside the request.

The canonical example from the book: you want to find spans from a backend service that came from a frontend service — but only when a specific feature flag is enabled. The frontend sets the flag in context; it propagates automatically on network calls; downstream services retrieve it and use it as a filter dimension.

Two things about baggage that surprise people:

  1. Baggage is independent of signals. Setting a baggage entry does not automatically decorate spans or metrics with it. Downstream services must explicitly read the value and add it to their own telemetry. This is a feature — it prevents unexpected cardinality explosions from values you only meant to transport.

  2. Baggage travels as HTTP headers. Never put sensitive data in baggage, especially for requests to third-party endpoints. Baggage.empty().makeCurrent() deliberately clears it when crossing an untrusted boundary.

The Baggage API mirrors the Context API’s shape exactly — immutable values, a Scope you must close. Real code from the book, setting a value with metadata and reading it back further down the call stack:

// Create a new baggage updating the current baggage
Baggage myBaggage = Baggage.current()
    .toBuilder()
    .put("session.id", webSessionId)
    .put("myKey", "myValue", BaggageEntryMetadata.create("some metadata"))
    .build();

// Make the new baggage current in this scope
try (Scope ignored = myBaggage.makeCurrent()) {
  // Retrieve values in other methods in this scope
  String mySessionId = Baggage.current().getEntryValue("session.id");
  BaggageEntry myValueEntry = Baggage.current().asMap().get("myKey");
  String myValue = myValueEntry.getValue();
  String myMetadata = myValueEntry.getMetadata().getValue();
}

// Clear baggage before an untrusted boundary
try (Scope ignored = Baggage.empty().makeCurrent()) {
  // Baggage will be empty in this scope
}

On the wire, that first put() call produces exactly the header format Part 5’s propagators section covers below — one baggage header, comma-separated entries, semicolon-separated metadata properties per entry:

baggage: session.id=abc123, myKey=myValue;some metadata
Diagram showing baggage propagating from a frontend service to a backend service via HTTP headers, plus a comparison of propagator header formats: W3C TraceContext, W3C Baggage, B3, and Jaeger. How Context Travels Across Services Service A (frontend) sets baggage: feature_flag=beta injects into request headers traceparent: 00-… baggage: feature_flag=beta Service B (backend) extracts context from headers reads baggage, decorates its spans with the flag (manual: baggage is not auto-added) Now queryable: "all spans from Service B where feature_flag=beta" Propagator formats (multiple can run simultaneously) W3C TraceContext default · W3C Rec. traceparent / tracestate W3C Baggage default single baggage header B3 (Zipkin) extension X-B3-* headers Jaeger extension uber-trace-id Composite propagators: run many at once — last one wins on conflicts. Put your preferred format last.

Figure: context and baggage travel as HTTP headers; multiple propagator formats can coexist, enabling gradual migration.

The Propagators API: Inject and Extract

The mechanism that moves context across the wire is the Propagators API — public interfaces with two operations:

  • Inject: take the current context, write it into a mutable carrier (outgoing request headers)
  • Extract: read a carrier, produce a new immutable context (incoming request)

OpenTracing’s failure here was instructive: no default format (a zoo of b3, X-B3-*, uberctx-*, ot-* headers), context coupled to tracing, and only one propagator per application — which made migrations painful. OTel decouples context from trace context and supports composite propagators: an ordered list where the last one wins on conflicting values.

The practical advice from the book: put your preferred propagator last in the composite. And the header formats:

W3C TraceContext (the default, now a W3C Recommendation):

traceparent: 00-8909f23beb409e44011074c591d7350e-d5d7d3b3a7d196ac-01
             ^^ ^-------------------------------^ ^--------------^ ^^
         version       16-byte trace ID          8-byte span ID   flags

The rightmost flag bit is sampled. tracestate carries vendor-specific key-values — vendors may add or update their own keys but must never delete others’.

W3C Baggage (also default):

baggage: key1=value1;property1, key2=value2

Configuring via the agent is one property: otel.propagators=tracecontext,baggage,b3,jaeger — and being able to run several at once is precisely what lets you migrate from a legacy tracing system without “breaking the chain.” Every service that understands any shared format keeps the transaction intact during the transition.

Why This Chapter Is the Make-or-Break One

Here’s the pattern the book keeps returning to: instrumentation produces data; context produces correlation; and correlation is the entire point. You can have the most beautifully instrumented services in the world, and if the context doesn’t propagate across one hop — one library that forgets to inject, one async task that drops the scope — you’re back to Part 1’s world: isolated telemetry, multi-team incident calls, graphs that someone has to align by hand.

The good news is the standard makes the glue nearly free. The Java agent handles injection and extraction for you. The formats are W3C Recommendations, not proprietary inventions. And baggage gives you correlation dimensions the trace system never heard of, propagated for free.

In Part 6, we zoom into the signal that consumes most of this context: tracing itself — spans, span kinds, and how to structure a trace that actually tells you what went wrong.


Next: Practical OpenTelemetry — Part 6: Tracing: Supercharged Structured Logs


References

Comments