Topic 39

Spans and Context Propagation

Spans

A trace is not a special object any service holds — it is an agreement. Every piece of work carries a span: a named, timed operation stamped with a trace_id shared by the whole request and a parent_span_id pointing at whoever called it. No single process ever sees the full trace; each one emits its spans independently, and the backend reassembles the tree from the IDs.

The agreement is enforced by context propagation: every outgoing call carries the IDs in a traceparent header, and the receiving side continues the tree instead of starting a new one. Where that hand-off breaks, the trace breaks — and knowing the three places it breaks is most of the debugging skill this topic teaches.

Trace, Span, Parent

A trace is a tree of spans sharing one 128-bit trace_id. Each span carries its own 64-bit span ID, a name (POST /checkout, SELECT bookings), a start time, a duration, and a parent_span_id identifying its caller. The root span — web handling the browser request at Harborline — has no parent. That is the entire data model: the waterfall view in Topic 38 is nothing more than this tree rendered with time on the horizontal axis, and every gap or overlap you read in it is a parent-child relationship plus two timestamps.

One trace — a root span and the children it parents
Trace
one shared trace_id
Root span · web
no parent
Child span · bookings
parent_span_id → web
traceparent hop: 00-…-01
Child span · cache-01 lookup
parent_span_id → bookings
Child span · payments charge
parent_span_id → bookings

Attributes, Events, Status

Three fields turn a timed tree into evidence. Attributes are key-value pairs on the span — http.request.method, db.system, harborline.route_id — the dimensions you will filter and group by later. Events are timestamped moments inside the operation; an exception with its stack trace is the canonical event. Status is one of Unset, Ok, or Error, and it matters more than it looks: the Error status is the single bit that tail sampling (Topic 42) and every status = error TraceQL query keys on. A span that failed but kept status Unset is invisible to both.

The W3C traceparent Header

Between processes, the context travels as one HTTP header standardized by the W3C Trace Context recommendation. Four dash-separated fields: a version byte, the 32-hex-character trace ID, the 16-hex-character ID of the calling span, and a flags byte whose last bit says whether this trace was sampled — the field that lets a sampling decision made at the root propagate to every child service.

# version - trace-id (32 hex) - parent span-id (16 hex) - flags
traceparent: 00-7c3a92d54cf1b8a2e6d90f14ab37c5e8-b7ad6b7169203331-01

A second header, tracestate, rides alongside for vendor-specific data and is safe to ignore until a vendor makes you care. The older B3 headers from the Zipkin lineage still appear in mixed fleets; OTel propagators can emit both formats during a migration, which is the standard escape hatch when half the fleet predates W3C.

Propagation Inside a Process

Between the incoming request and the outgoing calls, the context lives in in-process context — contextvars in Python, thread-locals elsewhere — which instrumentation reads whenever it creates a child span. This works invisibly until work leaves the request's execution context. Hand a job to a bare ThreadPoolExecutor or a fire-and-forget background task and the new thread starts with empty context: the child span has no parent to attach to, so it becomes the root of a fresh, disconnected trace.

Propagation Across the Queue

HTTP headers do not exist on a RabbitMQ message, so the hand-off must be done by hand — or by instrumentation that knows to do it. The producer injects traceparent into the message headers; the consumer extracts it and continues the tree. OTel's pika instrumentation handles both sides for Harborline. Without it, every notifier span exists in its own orphan trace, and "did the confirmation email for this slow booking actually go out" is a question the trace can never answer.

Reading a Broken Trace

Broken traces have a small symptom vocabulary, and each symptom names its cause. A service missing entirely from the waterfall means no instrumentation, or a proxy stripping the header before the service saw it. Two short traces where one long one should be means context died at a queue or a thread pool — the classic split at mq-01. An orphaned span whose parent_span_id points at a span the backend never received means the parent was dropped or unsampled. Learn these three shapes and a broken waterfall reads like an error message.

Common Mistakes
  • Running bookings behind a proxy or middleware stack that strips unknown headers — traceparent dies at the boundary, payments starts a new root, and every checkout produces two half-traces that each look complete on their own.
  • Offloading work to ThreadPoolExecutor or an async task without carrying context — the child span lands in a brand-new trace, and the waterfall shows bookings "finishing" in 40 ms while the real work happens in an invisible sibling trace.
  • Publishing to mq-01 without injecting traceparent into message headers — notifier traces exist but never connect to their checkout, so the email question stays unanswerable.
  • Putting unbounded values in span names (GET /booking/48213) instead of attributes — the backend's span-name index explodes and grouping by operation becomes useless; the name is GET /booking/{id}, the ID is an attribute.
  • Recording an exception in a log line but leaving the span status Unset — Topic 42's keep-all-errors policy and every status = error TraceQL query silently miss the failure.
Best Practices
  • Standardize on W3C traceparent as the propagation format fleet-wide, and configure a composite propagator only where a legacy B3 system genuinely requires it.
  • Set span status to Error and attach the exception as a span event whenever a request fails — that single bit is what makes failed traces findable and tail-sampleable.
  • Name spans by operation template, never by instance: POST /checkout and SELECT bookings, with the variable parts as attributes.
  • Verify propagation empirically after wiring each hop: send one request, pull the trace, and count services in the waterfall — Harborline's checkout must show web, bookings, and payments plus the db-01 and cache-01 client spans before the chapter moves on.
Comparable toolsZipkin B3 headers — the main pre-W3C propagation survivorAWS X-Amzn-Trace-Id — X-Ray's own header, bridged by an OTel propagatorDatadog x-datadog-trace-id historically — now speaks W3CStandard W3C Trace Context — the Recommendation every vendor converged on

Knowledge Check

How does a trace backend reconstruct the waterfall from spans that arrive independently from five services?

  • It sorts all spans by timestamp and nests each one under the nearest earlier span
  • It groups spans by trace_id and links each span to its parent_span_id
  • The root service assembles the full tree in memory and ships it as one object
  • It infers the hierarchy from the service dependency graph

Which part of the traceparent header carries the sampling decision to downstream services?

  • The 32-character trace ID
  • The leading version field
  • The tracestate companion header
  • The flags byte at the end

A checkout appears in the backend as two short traces instead of one long one: web plus bookings in the first, notifier alone in the second. Where did context most likely get lost?

  • At the queue — the producer never injected traceparent into the mq-01 message
  • A proxy between web and bookings stripped the header
  • The sampler dropped half the spans of one trace
  • Clock skew between app-01 and app-02 split the trace into two by timestamp gaps

A request in payments throws an exception that gets logged, but the span keeps status Unset. What is the consequence?

  • The span is never exported, so the trace arrives incomplete
  • The waterfall breaks because children cannot attach to a failed span
  • Error-based sampling policies and status filters never see the failure
  • The exception log line is discarded along with the span

You got correct