DevOps in Depth · Distributed Tracing & Telemetry

Distributed Tracing & Telemetry

Monitoring & observability introduced traces as one of three telemetry pillars and moved on. This page stays. A real request today might cross a gateway, three internal services, a message queue, and a database before it returns — and the moment it's slow or wrong, "grep the logs around that timestamp" stops working almost immediately, because nothing yet says which log lines across which services belong to which request. This page is the machinery that fixes that: what a span actually contains, how a trace ID survives a hop across a network call or a queue (and how it doesn't, if nobody wires it up), what a log line needs before it can be joined against a trace, and OpenTelemetry — the vendor-neutral instrumentation standard that makes all of it portable across Jaeger, AWS X-Ray, Datadog APM, and whichever backend you're running next year.

☺ Explain it like I'm 10

Picture a relay race where the baton itself carries a sticker with the race's ID number, and every runner writes that number on their own scoresheet the instant they touch the baton. The baton also has a tiny built-in stopwatch that starts the second a runner picks it up and stops the second they hand it off. At the end of the race you don't have to guess which runner was slow — you collect every scoresheet, sort them by the sticker's number, and the stopwatch readings tell you exactly which leg of the race ate the time. A trace is the sticker plus the stopwatch. Losing the sticker at any handoff — a runner who forgets to copy the number onto the next scoresheet — is the single most common way this whole system quietly breaks.

🐘Your host for this topic: Ellie the Elephant — she already holds the metrics, logs, and traces from the observability survey. This is her going deep on the one pillar that only means anything if it survives a trip across the network.

From a telemetry pillar to a load-bearing system

☺ Like you're 10: The last page told you traces exist. This page is the actual plumbing that keeps one trace glued together as a request bounces between services — and the standard almost everyone has converged on for building that plumbing instead of hand-rolling it.

Metrics, logs, and traces got one paragraph each in the observability survey, and that's the right amount of space for an introduction — but traces are structurally different from the other two in a way that deserves its own page. A metric is emitted and stored by one process; a log line is written and shipped by one process. A trace, by definition, is not: it only exists as a coherent object if every process a request touches agrees to carry the same identifier forward and hand it to the next process in line. Nothing in TCP/IP, HTTP, or a message broker does that for you automatically. It is an engineering discipline someone has to actually implement, and most of the ways production tracing quietly breaks trace back to exactly one hop where that discipline lapsed.

This page covers that discipline in three layers: the shape of a trace and a span, the standard for carrying a trace ID across a boundary (HTTP call, queue hop, or otherwise), and how a structured log line earns the right to be pulled up next to a trace instead of grepped for by hand. Then it covers OpenTelemetry, the project that has absorbed most of this into a library you install rather than protocol you re-derive from a spec every time you pick up a new language.

Anatomy of a trace: spans, context, and the waterfall

☺ Like you're 10: A trace is the whole relay race. A span is one runner's leg of it — with a start time, an end time, a name for what they were doing, and a note about who handed them the baton.

A trace is a set of spans that share one trace_id. A span represents one unit of work — an HTTP request, a database query, a function call you cared enough to name — and carries a fixed shape regardless of which language or library produced it:

Stitch every span's parent_span_id back to its parent and you get a tree — usually drawn as a waterfall, one horizontal bar per span, nested under its parent and positioned on a shared time axis. Reading a waterfall is the single most common motion in a tracing-driven incident: find the longest bar, that's roughly where the time went; find the red bar, that's roughly where the error happened.

Synchronous chain — spans nest, parent to child API Gateway SERVER · root span Checkout Service CLIENT → SERVER hop DB write 0ms 80ms 160ms 240ms Async hop — linked by trace_id, not nested The payments queue hop breaks the waterfall — the consumer span can start seconds later, in a different process, on its own branch entirely. What ties it back is one shared field: trace_id. PRODUCER linked, not parented Payments Worker CONSUMER · same trace_id

Propagating a trace ID across a service boundary

☺ Like you're 10: The trace's sticker number has to physically travel inside the request itself — as a header — or the next service has no way to know it's part of the same race at all.

Context propagation is the actual mechanism, and it comes down to one rule: the calling span's trace_id and span_id travel as request headers, and the receiving service reads them before creating its own span as a child. The W3C Trace Context specification standardized the header format in 2020, and it's what OpenTelemetry uses by default:

GET /checkout HTTP/1.1
Host: api.acme.io
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
tracestate: dd=s:1;congo=t61rcWkgMzE

# 00                                 spec version
# 4bf92f3577b34da6a3ce929d0e0e4736   trace-id    (32 hex chars / 16 bytes)
# 00f067aa0ba902b7                   parent-id   (16 hex chars / 8 bytes — the CALLER's span_id)
# 01                                 trace-flags (bit 0 = sampled: 01 sampled, 00 not)

The receiving service parses traceparent, keeps the same trace_id, treats the incoming parent-id as its new span's parent, mints its own fresh span_id, and — critically — forwards a rewritten traceparent (pointing at its own span_id) to whatever it calls next. tracestate rides alongside it as an ordered, vendor-specific extension slot, letting multiple tracing systems annotate the same request without stepping on each other. A separate, related header — baggage — propagates arbitrary business context the same way (baggage: customer.tier=gold,checkout.experiment=v2), so any downstream service can read it and tag its own spans, without that context needing to be threaded through every function signature by hand.

W3C Trace Context is the current standard, but it's not the only format you'll meet in the wild: B3 propagation (from Zipkin, still emitted by older Spring Cloud Sleuth and Envoy configs) uses separate X-B3-TraceId / X-B3-SpanId / X-B3-ParentSpanId / X-B3-Sampled headers, or a single condensed b3 header. OpenTelemetry's SDKs can read and write both formats, which matters the moment your trace crosses from a newly instrumented service into a legacy one, or through a proxy layer (Envoy, an API gateway) that only speaks B3.

⚠ Watch out

Propagation fails silently, not loudly — that's what makes it the single most common tracing bug. A load balancer or API gateway that strips unrecognized headers, a thread pool that reuses a worker thread without carrying its context (a classic Java gotcha), a queue client that never copies traceparent into the message's own headers, or simply forgetting to configure the HTTP client's auto-instrumentation — none of these throw an exception. They just quietly produce two disconnected traces where you expected one, and nobody notices until an incident needs the connection that isn't there. And because baggage travels as unencrypted plaintext through every hop and can end up copied straight into logs, never put secrets, tokens, or anything regulated in it — treat it exactly as carefully as you'd treat anything else that touches a credential boundary.

Asynchronous hops need one more step, because there's no synchronous call to attach a header to. Publishing to a queue means manually copying the current traceparent into the message's own headers or attributes (Kafka record headers, an SQS MessageAttribute) at publish time, and reading it back out at consume time to start the next span as a link rather than a direct child — which is exactly the branch drawn in the diagram above. Most OpenTelemetry instrumentation libraries for Kafka, SQS, and RabbitMQ do this automatically once installed; hand-rolled queue clients are where this quietly gets skipped.

Structured logs a trace can actually join against

☺ Like you're 10: A log line becomes useful to a trace the moment it writes down the same sticker number the trace is using — as its own labeled field, not buried in a sentence.

A free-text log line like ERROR payment authorization failed for order 88213 is readable by a human and useless to a machine trying to join it against a trace. Structured logging — one JSON object per line, with consistent field names — fixes that, but only if trace_id and span_id are among the fields, written by the logging library at the moment the log call happens, from whatever span is currently active in that thread or async context:

{"timestamp":"2026-08-16T14:02:11.408Z","level":"error","service":"checkout",
 "message":"payment authorization failed","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736",
 "span_id":"00f067aa0ba902b7","customer_id":"4471","http.status_code":402,
 "payment.provider":"stripe","payment.decline_code":"insufficient_funds"}

Once every service's logger is configured this way, the join is trivial in either direction: from a slow or errored span in your tracing backend, pivot straight to every log line across every service carrying that exact trace_id, in whatever log backend holds them (the ELK stack, Loki, or a hosted equivalent) — no more grepping by approximate timestamp and hoping you've got the right five-minute window on the right host. The wiring is almost always automatic once it's set up once: Python's logging module, Node's pino/winston, and Java's MDC (Mapped Diagnostic Context) can all be configured to pull trace_id/span_id from OpenTelemetry's active context and stamp every log call with it, with zero changes to the call sites themselves.

The same correlation idea shows up one layer over in metrics, under the name exemplars: a single data point in a Prometheus histogram bucket can carry the trace_id of one specific request that landed in that bucket, so a spike on a latency graph in Prometheus or Grafana isn't just a number — it's a clickable link straight into one representative trace of the requests that caused it. That's the three pillars closing the loop the observability survey promised: a metric says something's wrong, an exemplar or a trace query narrows it to specific requests, and the joined log lines say exactly what happened.

◆ Key idea

Don't confuse "logs correlated to a trace" with "logs stored inside a trace." Span events can hold a small amount of detail, but a tracing backend is priced and built for span metadata, not for ingesting your full log volume — logs stay in your log backend, correlated by a shared trace_id field, not duplicated into spans. Trying to make traces your log store is a fast way to make both expensive and neither good.

OpenTelemetry: one instrumentation standard, many backends

☺ Like you're 10: Instead of every tool inventing its own private way to measure things, OpenTelemetry is the one shared measuring tape everyone agreed to use — so you can point the same measurements at whichever tool you like.

OpenTelemetry (often shortened to OTel) is a CNCF project formed in 2019 by merging two earlier, competing efforts — OpenTracing (the tracing-focused API that Jaeger and Zipkin grew up around) and OpenCensus (Google's combined metrics-and-tracing library) — into one vendor-neutral standard for producing metrics, logs, and traces. It has since become one of the most active projects in the CNCF, and for good reason: before it existed, instrumenting an application for Jaeger versus Datadog versus X-Ray meant three separate SDKs, three separate sets of decorators wrapped around your code, and re-instrumenting everything if you ever switched. OpenTelemetry's whole value proposition is decoupling how you instrument code from where the telemetry ends up.

The architecture has three distinct layers, and confusing them is the most common source of OTel confusion for newcomers:

Your service instrumented with the OTel API + SDK OTLP (gRPC/HTTP) OpenTelemetry Collector receive (OTLP, Jaeger, Zipkin…) process (batch, sample, redact, add attributes) export — fan out to backends a standalone process, not a library Jaeger self-hosted, OTLP-native AWS X-Ray via the ADOT Collector Datadog APM OTLP ingest on the Agent same instrumentation, any backend — change the exporter config, not the code

Two ways to actually get spans out of your code, and most real services use both: auto-instrumentation patches common libraries (HTTP clients and servers, database drivers, queue clients) with zero code changes — you install an agent or wrap your start command, and every HTTP call and DB query gets a span for free. Manual instrumentation uses the SDK API directly, for the business logic no library can see on your behalf:

# Auto-instrumentation: no code changes, wrap the start command —
# every HTTP call, DB query, and queue publish gets a span for free.
opentelemetry-instrument \
  --traces_exporter otlp \
  --service_name checkout \
  python app.py

# Manual span, for the part auto-instrumentation can't see —
# your own business logic, not a library call:
from opentelemetry import trace
tracer = trace.get_tracer("checkout.pricing")

with tracer.start_as_current_span("apply_discount_rules") as span:
    span.set_attribute("customer.tier", customer.tier)
    span.set_attribute("discount.rules_evaluated", len(rules))
    result = apply_rules(customer, cart)
    span.set_attribute("discount.amount_cents", result.amount_cents)

One more piece makes cross-service, cross-language traces actually comparable: semantic conventions, OpenTelemetry's standardized attribute names (http.method, http.status_code, db.system, messaging.system). Without them, a Java team's "did this call fail" attribute and a Python team's version would drift into two different key names, and no query or dashboard could treat them as the same thing. With them, a span from any instrumented service in any supported language uses the same vocabulary — which is what lets one Collector, one set of sampling rules, and one dashboard work across an entire polyglot fleet.

✎ Try it

On a machine with Docker, run docker run -d --name jaeger -p 16686:16686 -p 4317:4317 jaegertracing/all-in-one:latest, then point any small OTel-instrumented app at it with OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 and send it a few requests. Open http://localhost:16686 and search by service name — you'll see the exact waterfall shape described above, generated from your own code, in about five minutes. It's also the fastest way to feel the difference tracing makes in the inner loop: reproducing a bug locally with a trace attached beats reproducing it blind, every time.

Sampling: you can't keep everything, so decide on purpose

☺ Like you're 10: Recording every single request forever gets expensive fast, so you have to choose which ones to keep — either by rolling dice before you know what happens, or by watching the whole thing and then deciding.

A busy service can generate more trace data per day than makes sense to store, let alone pay a vendor per span for, so nearly every real deployment samples — keeps some fraction of traces and discards the rest. There are two fundamentally different ways to decide which ones survive:

Head-based samplingTail-based sampling
Decision madeAt the root span, before the request has even runAfter the whole trace has finished, once every span is in hand
Where it runsIn the SDK, in-process — cheap, no bufferingIn the Collector, which must hold every span in memory until the trace completes
Typical ruleKeep N% of traces at random (e.g. 5%)Keep every trace with an error or over a latency threshold; sample the rest at N%
Catches rare errors?Only by luck — a 0.1% error rate at 5% sampling means most failures never get recordedYes, by design — error and slow traces are kept regardless of the random roll

Tail-based sampling is strictly more useful for incident response — the traces you want most, the failing and the slow ones, are exactly the ones a random head-based roll is likeliest to throw away — but it costs more, because the Collector has to buffer entire traces in memory until it can see whether they qualify, and in a horizontally scaled fleet that means all of a trace's spans have to be routed to the same Collector instance to be evaluated together. Plenty of teams run head-based sampling for routine traffic and lean on tail-based rules specifically for "always keep errors" as the two aren't mutually exclusive.

◆ Key idea

Once a decision is made, it has to be honored everywhere downstream — that's what the sampled bit in traceparent (the trailing 01 or 00) is for. If every service re-rolled its own sampling decision independently, you'd get a torn trace: some spans present, others silently missing, with no way to tell whether that's because nothing happened there or because that one hop's dice came up wrong. Parent-based sampling — every child honoring the root's decision — is what keeps a kept trace complete instead of full of unexplained gaps.

Choosing (and switching) a tracing backend

☺ Like you're 10: Because everyone agreed on the same measuring tape, you can hand your measurements to a different workshop later without measuring everything all over again.

The backend is where spans actually land, get stored, and get queried — and because OpenTelemetry decouples instrumentation from destination, this is close to a config change, not a re-instrumentation project:

BackendHosting modelIngests OTLP?Natural fit
JaegerSelf-hosted, CNCF project originated at UberYes — native OTLP ingestion since around Jaeger 1.35, no separate agent requiredTeams that want to own their tracing storage and avoid a per-span vendor bill
Grafana TempoSelf-hosted or Grafana CloudYes, OTLP-native by designTeams already standardized on Grafana + Loki + Prometheus who want traces indexed only by trace ID, kept cheap at high volume
AWS X-RayManaged AWS serviceVia the AWS Distro for OpenTelemetry (ADOT) Collector, which translates OTLP into X-Ray's segment formatWorkloads already living in API Gateway, Lambda, and ECS, where X-Ray is wired in with minimal setup
Datadog APMCommercial SaaSThe Datadog Agent accepts OTLP directly on recent versions, alongside its own native trace format — verify the current support matrix against Datadog's docs before committing to itTeams that want traces, metrics, logs, and infra monitoring correlated in one commercial UI

The practical upshot: a team that instruments with the OTel API and SDK and routes everything through a Collector can start on self-hosted Jaeger, add a second export to a commercial backend for a proof of concept, and drop Jaeger later — all by editing the Collector's exporters block, with zero changes to application code:

receivers:
  otlp:
    protocols: { grpc: {}, http: {} }

processors:
  batch: {}
  tail_sampling:
    policies:
      - name: keep-errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: keep-slow
        type: latency
        latency: { threshold_ms: 500 }
      - name: sample-rest
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

exporters:
  otlp/jaeger:
    endpoint: jaeger-collector:4317
  otlp/datadog:
    endpoint: otel-intake.datadoghq.com:4317
    headers: { "dd-api-key": "${DD_API_KEY}" }

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [tail_sampling, batch]
      exporters: [otlp/jaeger, otlp/datadog]

Putting it together: what good coverage looks like

☺ Like you're 10: All four pieces — spans, propagation, joinable logs, and one shared standard — only pay off together. Skip any one and there's a hole an incident will eventually find.

A service with real tracing coverage has all four pieces from this page working at once: spans with span kinds and semantic-convention attributes, a traceparent that survives every hop it makes — including its queue hops, wired by hand or by instrumentation — structured logs stamped with trace_id and span_id on every line, and a sampling policy that's a deliberate choice rather than an accident of default settings. None of this is optional if the goal is answering "why was this one specific request slow" rather than "the p99 went up sometime this afternoon" — which is precisely the gap between monitoring and observability the survey page opened with.

This is also where tracing stops being a standalone concept and starts touching almost everything else in this course: a canary in deployment strategies is far easier to judge when you can compare trace latency between the old and new version directly instead of waiting on aggregate dashboards; an incident gets diagnosed in minutes instead of hours once the responder can jump from an alert straight to the one trace that explains it; a error budget burns on the same latency and error data a trace is built from; and a game day is the cheapest way to find out your queue hop was never actually propagating context, before a real incident finds out for you.

🎬 At the Ship-It Guild
🐘

Ellie: I've got three pillars on this checkout failure, but the trace stops dead the moment it hits payments. Nobody's home past that point.

🦊

Foxy: Stops? A trace doesn't just stop.

🦫

Benny the Beaver: That's on me — I put a Kafka hop between checkout and payments last sprint and never copied the trace context into the message headers. Once it hits the queue, the ID's gone.

👺

Gizmo: Just match them up by timestamp! Close enough, right? 🤑

🐢

Timmy the Turtle: "Close enough" falls apart the second you've got more than one request in flight per second. Propagate the header through the message attributes, or the trace's a guess, not a fact.

🦫

Benny the Beaver: Fine — swapping the hand-rolled producer for the OTel Kafka instrumentation. It copies traceparent into the message headers for me, so I stop being the weak link.

🐘

Ellie: Do that, and the logs already carry trace_id on every line — the second it's stitched back together, I can pull every log across every service in one query.

✓ Checkpoint

1. Name the fields that fix a span's position inside a trace, and explain the difference between a trace and a span. 2. What does a W3C traceparent header carry, and why does propagating it across a message queue take extra work compared to an HTTP call? 3. What has to be true of a log line before it can be "joined" against a trace, and how do exemplars connect that same idea to metrics? 4. Name the three layers of the OpenTelemetry architecture and what each one is responsible for. 5. What's the core tradeoff between head-based and tail-based sampling, and why must every downstream service honor the root span's sampling decision rather than re-deciding on its own? 6. Name two tracing backends from this page and explain, in one sentence, what OpenTelemetry buys you when switching between them.

Check your answers
  1. A span carries a trace_id (shared by every span in the trace), a span_id (unique to itself), and a parent_span_id (pointing at whoever called it). A trace is the full set of spans sharing one trace_id; a span is one timed unit of work within it.
  2. It carries the spec version, the 32-hex-character trace-id, the caller's span_id as the parent-id, and a sampled flag. HTTP calls propagate it as a header automatically once instrumented; a queue hop has no synchronous request to attach a header to, so the trace context has to be manually copied into the message's own headers or attributes at publish time and read back out at consume time.
  3. The log line needs to be structured (JSON, not free text) and carry trace_id/span_id as explicit fields, written by the logging library from the currently active span. Exemplars do the same job for metrics — attaching a specific request's trace_id to one data point in a metric like a histogram bucket, so a spike on a graph links straight to a representative trace.
  4. The API (stable interfaces your code calls to create and annotate spans), the SDK (the implementation behind it — samplers, processors, exporters), and the Collector (a standalone process that receives OTLP, processes it, and fans it out to one or more backends).
  5. Head-based sampling decides at the root span before the request runs, cheaply, but catches rare errors only by luck; tail-based sampling decides after the whole trace completes, so it can reliably keep every error or slow trace, at the cost of the Collector having to buffer full traces in memory. Every downstream service must honor the root's sampling decision (via the sampled flag in traceparent) or you get a torn trace — some spans kept, others silently missing, with no way to tell why.
  6. Any two of Jaeger, Grafana Tempo, AWS X-Ray, or Datadog APM. Because OpenTelemetry decouples instrumentation from destination, switching backends is a matter of changing the Collector's exporter configuration, not re-instrumenting application code.