What you'll learn
Quick Answer
Distributed tracing assigns every incoming request a single trace ID, then wraps each unit of work — a function call, a database query, a call to another service — in a span that carries that trace ID plus its own span ID and the ID of whichever span called it. Collecting every span that shares a trace ID and connecting them by parent-span ID reconstructs the full call tree with per-step timing, so you can see exactly which service or query in a multi-service request was slow.
One request, five log files
In a monolith, one slow request means one log file to grep. You find the request by its timestamp, read down, and see everything it did.
In a microservice architecture, a single request to /checkout might touch an API gateway, an inventory service, a payment service, and a notification service — four processes, four separate log streams, none of which know about each other. Each one logs "handling request" with no shared identifier tying the four together.
Now try to answer "why was this particular checkout slow?" You'd be matching approximate timestamps across four files, made worse by the fact that dozens of other requests are being logged concurrently and interleaved with the one you care about. It doesn't scale past a handful of services, and it's exactly the kind of debugging session that eats an afternoon.
Distributed tracing solves this by giving every request one ID that follows it through every service it touches, so instead of four disconnected logs you get one connected timeline.
Trace ID, span ID, parent span ID
A trace starts when a request first enters the system — usually at a gateway or the first service that receives it. That entry point generates a trace ID, a single identifier for the entire request's journey.
Every unit of work inside that journey — handling the HTTP request, querying a database, calling a downstream service — becomes a span. Each span records the same trace ID, a unique span ID of its own, the parent span ID of whichever span triggered it, a name, and start/end timestamps.
The trace ID has to be passed along on every outgoing call — as an HTTP header (the W3C standard is traceparent), a message queue attribute, or a gRPC metadata field — so the next service can create its own spans under the same trace.
Once every span for a trace ID is collected, connecting each span to its parent by parentSpanId produces a tree: the root span is the original request, and every span underneath shows exactly what it triggered and how long each piece took.
Propagating a trace ID — real run
Here's a minimal version: a request handler generates a trace ID and a root span, then calls two "services" that each create their own child span using the same trace ID and the root's span ID as their parent.
async function fetchOrders(ctx) {
const spanId = shortId();
const span = startSpan({ traceId: ctx.traceId, spanId,
parentSpanId: ctx.parentSpanId, name: 'fetchOrders' });
await delay(25);
await queryDatabase({ traceId: ctx.traceId, parentSpanId: spanId });
span.end({ orderCount: 3 });
}
Rendering the recorded spans as a tree, from a real run:
trace 8e14f59c8d8c9600
GET /dashboard (span=ee35b519 parent=none, 75.1ms)
└─ fetchUser (span=60c2de56 parent=ee35b519, 27.3ms)
└─ fetchOrders (span=0b7ffc44 parent=ee35b519, 47.1ms)
└─ queryDatabase (span=83e18928 parent=0b7ffc44, 20.1ms)
Every span shares the trace ID 8e14f59c8d8c9600, and the indentation comes purely from following each span's parentSpanId back to its caller — this is the same shape a tracing UI renders as a waterfall.
Where propagation breaks
The whole system depends on one thing: every hop forwarding the trace context to the next. Miss it once and the trace tree gets cut off at that point.
The usual culprits: a new HTTP client that isn't wired into the tracing middleware, a fire-and-forget queue publish that doesn't copy the trace header onto the message, or a background job kicked off from a request handler that doesn't carry the context with it. In every case, the downstream work either shows up as its own disconnected trace or doesn't show up as traced at all.
It gets subtler across await boundaries inside a single service. "Which span is currently active?" needs to survive callbacks, promise chains, and event loop hops — which is why runtimes need explicit support for this (Node's AsyncLocalStorage, for example). Without it, a span created inside a callback can silently attach to the wrong parent, or none at all.
The practical habit: whenever you add a new way for one part of the system to call another — a new client library, a new queue, a new job runner — check whether it's actually propagating the trace context before you trust the tracing to be complete.
What you'd actually use in production
You'd rarely hand-roll span IDs and parent pointers the way the demo above does. OpenTelemetry is the current standard: vendor-neutral SDKs that auto-instrument common HTTP clients, database drivers, and frameworks, so spans get created and context gets propagated without you writing that plumbing by hand.
Those spans get exported to a backend that renders the trace tree visually — Jaeger, Zipkin, and Grafana Tempo are common open-source choices; Datadog and New Relic are common managed ones. What you see is the same waterfall shape as the printed tree above, just clickable, searchable, and aggregated across every request instead of one.
Knowing the underlying model — trace ID, span, parent span ID — is still what lets you diagnose it when a trace looks wrong: a broken tree, a missing branch, or a span that's parented to the wrong caller almost always traces back to one hop that didn't propagate context correctly.
