Observability & Tracing · OpenTelemetry course

Lesson 02

Context propagation: traceparent & baggage

Lesson 1 left one thing unexplained: how does payment-service know it belongs to a trace that started in gateway? Nothing is shared between those two JVMs except an HTTP request. So the answer has to be in the request. This lesson opens that request and reads the two headers that make distributed tracing possible.

The one win

By the end you can read a raw traceparent header field by field, and you can say exactly what baggage is, what it is not, and why it never shows up in Jaeger unless you make it.

The problem: a request leaves the process

Predict first

order-service calls payment-service over HTTP. The trace ID lives in memory in the caller's JVM. The callee is a different process, possibly a different machine. How does the trace ID get there? Answer in one word before reading on.

One word: headers. There is no magic, no shared database, no coordination service. The tracing context is serialised into HTTP headers on the way out and parsed back out of them on the way in. OpenTelemetry calls that whole mechanism context propagation, and the component that does it is a propagator.[context propagation]

order-service (JVM A) payment-service (JVM B) ┌────────────────────────┐ ┌────────────────────────┐ │ active span │ │ │ │ trace_id = 4bf92f35… │ │ │ │ span_id = 00f067aa… │ │ │ │ │ │ │ ▲ │ │ │ INJECT │ │ EXTRACT │ │ │ ▼ │ │ │ │ │ propagator ──────────┼── HTTP request ─────┼──► propagator │ └────────────────────────┘ traceparent: … └────────────────────────┘ baggage: … ▲ ▲ writes headers reads headers, from context makes it the parent context INJECT = context ──► carrier (headers) on every outbound call EXTRACT = carrier (headers) ──► context on every inbound request

The propagator is the only reason a trace survives a process boundary. Default format across the ecosystem: W3C Trace Context. [src]

In Spring Boot 3.x you never call inject/extract yourself — the instrumentation on RestTemplate, WebClient and Spring MVC does it for you. That is precisely why a trace "just works" across services once the dependencies are right.

traceparent, dissected

Here is a real one. Four fields, separated by hyphens, all lowercase hex:[w3c]

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 ─┬ ────────────────┬─────────────── ───────┬──────── ─┬ │ │ │ │ version ─┘ │ │ └─ trace-flags 2 hex digits │ │ 2 hex digits always "00" today │ │ 01 = SAMPLED │ │ 00 = not sampled trace-id ───┘ │ 32 hex digits (16 bytes) └── parent-id THE SAME on every span 16 hex digits (8 bytes) in the whole trace = the CALLER's span id becomes the parent of all-zeroes = invalid the next span all-zeroes = invalid

Two details worth burning in:

In an inbound traceparent, what is the parent-id field?

There is a sibling header, tracestate, which carries vendor-specific key/value pairs alongside traceparent (max 32 entries). You will rarely touch it; it exists so multiple tracing systems can coexist on one request without trampling each other.[w3c]

Baggage — the key/values that ride along

Predict first

Only your gateway saw the auth token, so only the gateway knows this request belongs to tenant acme on the gold tier. Four hops later, payment-service wants that tenant ID. Without baggage, what are your options? Think for ten seconds before reading on.

Realistically two, and both are bad: thread tenantId through every method signature and every HTTP client in four services, or simply don't have it. Baggage is the third option. It is a key/value store that propagates alongside the trace context — set it once at the edge, read it anywhere downstream.[baggage]

gateway order-service payment-service ┌────────┐ ┌────────┐ ┌────────┐ │ SET │ │ │ │ READ │ │ tenant │ │ │ │ tenant │ └───┬────┘ └───┬────┘ └───┬────┘ │ traceparent: 00-4bf92f35…-00f067aa…-01 │ │ baggage: tenantId=acme,tier=gold │ └──────────────────►└───────────────────────►┘ TWO SEPARATE HEADERS, travelling together but independent: traceparent = who this request IS (ids + sampling flag) baggage = what YOU attached (arbitrary key/values)

“Is baggage a signal?” — the honest answer

You will see baggage called “not a signal” all over the internet, and Lesson 1 leaned on that shorthand. It is worth being precise, because the shorthand is wrong on the letter and right on the spirit.

On the letter: OpenTelemetry's own Signals page says “OpenTelemetry currently supports:” and then lists four items — Traces, Metrics, Logs, Baggage. It lives at /docs/concepts/signals/baggage/. By the project's own taxonomy, baggage is in the list.[signals]

On the spirit: the baggage page itself opens by defining it as “Contextual information that is passed between signals” — a description no one would write about traces. And every property that makes traces, metrics and logs alike is missing from baggage:

TRACES METRICS LOGS │ BAGGAGE ────── ─────── ──── │ ─────── has a data model yes yes yes │ no serialised into OTLP yes yes yes │ no exported to a backend yes yes yes │ NO stored / queryable yes yes yes │ no travels between svcs no no no │ YES The three on the left are TELEMETRY: produced, exported, stored. Baggage is CONTEXT: it rides the request and dies with it.

So the accurate sentence is: baggage is listed as a signal, but it is the only one that carries no telemetry. Nothing you put in baggage ever reaches Jaeger or Dynatrace on its own — which is exactly the trap in the next section.

What does a backend like Jaeger receive from baggage?

The trap: baggage is not span attributes

This is the one that costs people an afternoon. You set tenantId in baggage at the gateway, open Jaeger expecting to filter traces by it, and it is nowhere to be found. The value propagated perfectly. It was simply never recorded onto a span.

OpenTelemetry states it plainly: baggage "is a separate key-value store and is unassociated with attributes on spans, metrics, or logs without explicitly adding them."[baggage]

LIVES ON CROSSES APPEARS IN THE WIRE JAEGER ───────────────────── ─────────── ───────── ─────────────────── span context the request yes as the trace tree (traceparent) (in flight) always BAGGAGE the request yes, ✗ NO — not unless (in flight) if declared you copy it across span attributes ONE span no yes baggage ──╳──► span attributes NOT automatic baggage ──────► readable in code yes, anywhere downstream

Attributes attach to one span and travel to the backend. Baggage attaches to the request and travels to the next service. Opposite directions — that is the whole confusion in one line.

To make baggage visible in your backend you must explicitly read it and set it as a span attribute. Some languages ship a Baggage Span Processor to automate that; in Spring Boot, the tag-fields property below does it for you.

You set userId in baggage, but cannot filter by it in Jaeger. Why?

On the wire

W3C standardises a lowercase baggage header, separate from the trace context spec:[w3c baggage]

baggage: userId=alice,serverNode=DF%2028,isProduction=false └────┬────┘ └───────┬───────┘ └────────┬────────┘ entry entry entry · comma-separated key=value entries · values are percent-encoded (DF%2028 → "DF 28") · optional ;properties may follow an entry · LIMITS: max 64 entries, max 8192 bytes total over the limit, entries may be dropped — but never a partial entry

In your Spring Boot 3.x stack

Everything lives under management.tracing.baggage.*, auto-wired by Actuator, enabled by default:[boot props]

management: tracing: baggage: enabled: true # default true remote-fields: [tenantId, tier] # what actually crosses the wire correlation: enabled: true # default true fields: [tenantId] # ──► MDC, so it lands in log lines tag-fields: [tenantId] # ──► span attributes (fixes the trap)

Three notes specific to your stack, because you are on the OTel bridge and not Brave:

Why "no secrets in baggage" is a hard rule

It is not tidiness. Instrumentation propagates baggage automatically on every outbound call the rest of the request tree makes — including calls to third parties. OpenTelemetry warns that "sensitive baggage items can be shared with unintended resources, like third-party APIs," and that "there are no built-in integrity checks to ensure that baggage items are yours" — so anything upstream can inject keys you will then read as trusted.[baggage]

W3C's rule is the one to memorise: ensure "no proprietary or confidential information is stored in baggage, or … that baggage isn't present in requests that cross trust-boundaries."[w3c baggage]

The working rule

Fine: tenant ID, tier, feature-flag cohort, region, request origin. Never: auth tokens, emails, anything you would redact from a log. And remember every entry is bytes added to every downstream request — it is not free either.

Prove it — recall drill

Answer out loud first, then reveal

Do not scroll back. If you cannot answer one, that is the section to re-read.

1. You set userId in baggage at the gateway. Why can't you filter by it in Jaeger?

Because baggage is not span attributes. It propagated across every hop exactly as intended, but nothing ever wrote it onto a span, and only spans reach the backend. Fix it by copying baggage into attributes — in Spring, add the key to management.tracing.baggage.tag-fields.

2. Which header carries baggage, and how does it relate to traceparent?

A separate, lowercase baggage header. It is independent of traceparent, which carries only the trace id, the caller's span id and the sampling flag. Both travel on the same request and are injected by the same propagation machinery, but either can exist without the other.

3. Why is local-fields useless in your project specifically?

Because it is Brave-only. Spring's own property docs state "Local fields are not supported with OpenTelemetry," and your project uses micrometer-tracing-bridge-otel.

4. Bonus — read this header aloud: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

Version 00; trace id 4bf92f35… (32 hex digits, identical on every span in the trace); parent id 00f067aa… (16 hex digits — the caller's span id, which becomes this span's parent); flags 01 = sampled, so this trace is being recorded.

5. Bonus — is baggage one of OpenTelemetry's signals, or not?

Both, depending on what you mean, so answer precisely. OTel's Signals page does list it alongside traces, metrics and logs. But it is the only one of the four that has no data model, is never serialised into OTLP, and never reaches a backend — the baggage page defines it as “contextual information that is passed between signals.” The other three are telemetry; baggage is context that rides the request and dies with it.

Primary source — read this next

Ten minutes on OpenTelemetry → Concepts → Context propagation, then skim the header grammar in the W3C Trace Context spec. The spec is short and unusually readable — you only need §3.2.

I'm your teacher — interrogate me. Good next questions: “What happens if a service in the middle doesn't understand traceparent?”, “How does the sampling flag interact with management.tracing.sampling.probability?”, or “Show me baggage being set and read in real Spring code.”

Sources