HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 212 min read

Distributed Tracing

following one request across many services

Developer toolingdeveloper tooling / observability

The mental model

Picture a relay race. A single user request is the baton. It gets passed from the API gateway → auth service → orders service → payments → database. Logs are like each runner scribbling a note in their own diary. Useful — but nobody has the full race timeline. A trace is the baton itself carrying a shared ID, so afterward you can reconstruct the whole run: who ran when, and who was slow.

Two words to nail down:

  • Trace = the entire journey of one request (one shared trace_id).
  • Span = one unit of work inside it (a single service call, a DB query). Spans nest: a parent span has children.

The trick that makes it work: context propagation

Nothing is magic. The trace_id is literally passed as an HTTP header from service to service. The W3C standard header is traceparent:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
              │  └─ trace_id (32 hex) ──┘ └─ parent span_id ┘ └ flags

Each service reads that header, starts its own span as a child, and passes a NEW traceparent (same trace_id, its own span_id) to the next hop. Break that chain and your trace splits into orphans.

In practice (pseudo-Python with OpenTelemetry)

# incoming request: extract context from headers
ctx = extract(request.headers)
with tracer.start_as_current_span("orders.create", context=ctx) as span:
    span.set_attribute("order.id", order_id)
    # outgoing call: inject context into headers
    headers = {}
    inject(headers)                 # adds traceparent
    requests.post(PAYMENTS_URL, headers=headers, json=payload)

That extract → span → inject dance is the whole game. Most frameworks auto-instrument HTTP clients so you rarely write it by hand — but knowing it exists is what saves you when a trace mysteriously ends.

Where intermediate devs get bitten

  • Async gaps: sending work to a queue/thread pool often DROPS the context — the worker starts a brand-new trace. You must manually carry the context across the boundary.
  • Sampling ≠ logging. Tracing usually samples (e.g. 1–10% of requests) to control cost. That p99 outlier you're chasing may simply not have been sampled. Use tail-based sampling if you need the slow ones.
  • Cardinality: don't shove unbounded values (raw emails, full URLs) into span attributes — it explodes your backend's index and bill.
  • Clock skew: span timings come from different machines' clocks. A child looking like it started "before" its parent is usually skew, not a bug.

Why you care

When a request is slow, logs tell you WHAT happened in each box; a trace tells you WHERE the time went. A waterfall view showing payments took 1.8s of a 2s request points you at the culprit in seconds instead of grepping five log streams.

Your exercise

Take any two services you have (or two functions across a network call). Generate a trace_id at the entry point and pass it as a header/param all the way through. Then log it as trace_id=... in every service. Now grep one ID across all logs and reconstruct the timeline by hand. Once you feel that pain — and the payoff — reach for OpenTelemetry to automate it.

Question for you: in your current system, name ONE boundary where trace context would silently get dropped. (Hint: look for a queue, a background job, or a new Thread.)