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.

Frequently Asked Questions

What's the difference between distributed tracing and logging? Logs are independent lines of text per service with no built-in link between them. Tracing explicitly connects every step of one request across every service it touched, using a shared trace ID and parent-child span relationships.
What is a span in distributed tracing? A span is one unit of work inside a trace — a function call, a database query, an outbound HTTP call — recorded with a start time, end time, its own span ID, and the ID of the span that triggered it.
Does distributed tracing need special infrastructure? In production, yes — typically an OpenTelemetry SDK in each service plus a backend like Jaeger or a managed APM tool to collect and visualize the spans. The underlying concept, though, is just IDs threaded through calls.
What happens if a service doesn't propagate the trace context? Everything downstream of that missed hop either appears as a disconnected trace with no parent, or isn't traced at all. The rest of the trace tree above that point stays intact.
Is distributed tracing only for microservices? It's most valuable there, but it also helps inside a single service with many internal async steps — background jobs, retries, or a request that fans out into several parallel database calls.