← Back to Logs

How Distributed Tracing Actually Works

Try the interactive lab for this articleTake the quiz (6 questions · ~6 min)

Distributed tracing is one of those tools that looks obvious in a demo and slippery in production.

The demo is easy. A request enters a front end in Athens, touches an API in Berlin, calls a payment service in Sofia, writes a message for a worker in Warsaw, and a dashboard draws a neat waterfall with coloured bars. You click one trace and the whole path appears.

Production is where the interesting part starts. The trace exists only if every hop kept enough context to link itself to the same request. The bars line up only if each process recorded spans with sensible names, consistent clocks, and sane timestamps. The beautiful tree stays whole only if retries, queues, proxies, sampling, and SDK defaults did not quietly split it into fragments.

A distributed trace is not a cinematic replay of reality. It is a structured set of timing records emitted by several processes that agree, more or less, on request identity and parent-child relationships. The agreements matter more than the pictures.

This article explains the machinery behind those agreements. We will walk through what a trace really is, what lives inside traceparent, how spans are created and propagated across HTTP and async systems, what collectors and backends do with the data, how sampling changes the story, and why some traces tell the truth while others quietly lie.

Distributed tracing is request lineage, not better logging

A distributed trace is a record of how one logical request moved through several components.

That definition sounds simple, but it separates tracing from the two other observability tools people mix it up with all the time: logs and metrics.

A short comparison helps:

Signal Best at answering Typical shape Main weakness
Metrics "How much, how often, how bad?" counters, gauges, histograms weak on per-request causality
Logs "What did this component say happened?" discrete text or structured events hard to follow across many hops without correlation
Traces "Which path did this request take and where did time go?" spans linked into one graph cost and complexity rise with hop count and traffic volume

If latency on /checkout jumps from 140 ms to 900 ms, metrics tell you that something changed. Histograms show the distribution shift. Error rate counters tell you whether failures rose too.

Logs may tell you that orders-api waited on payments, that payments retried a card-network call, and that mail-worker was backlogged. But unless those log lines carry a shared request identifier and every team uses it consistently, you are still doing archaeology across several systems.

Tracing exists to preserve lineage. The core question is not just "what happened?" It is "what happened for this request, in this order, across these boundaries?"

That makes tracing especially useful when:

  • one user action causes several downstream calls
  • work crosses process, host, or queue boundaries
  • a slow request is slow only on some paths, not all paths
  • retries or fan-out multiply the amount of work behind one external request
  • infrastructure components, not just application code, participate in the path

It is less useful when the system is basically one process with one database. You can still instrument it, but the return is smaller because ordinary profiling, logs, and SQL timing already explain most incidents.

This is why tracing adoption tracks architecture. Monoliths can benefit from tracing, but microservice platforms, service meshes, background-job systems, and event-driven pipelines benefit far more because request lineage is harder to keep in your head.

The important consequence is that tracing does not begin in the dashboard. It begins in the protocol fields and SDK decisions that preserve lineage at every boundary.

Trace context is a small protocol, not a dashboard feature

Before there is a trace visualisation, there is trace context.

Trace context is the metadata that tells one component, "You are working on the same logical request as somebody upstream." In the modern web stack, the most common format is the W3C Trace Context standard. The key header is traceparent.

A representative example looks like this:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
tracestate: acme=edge=ath-1,tenant=retail

That one line carries four important fields:

  1. version: 00
  2. trace id: 4bf92f3577b34da6a3ce929d0e0e4736
  3. parent span id: 00f067aa0ba902b7
  4. trace flags: 01

The trace id identifies the whole trace. Every span that belongs to the same logical request should carry the same trace id.

The span id identifies one specific span. In traceparent, the span id field is the parent span that the next hop should attach itself to. When a downstream service receives this header and opens a server span, that new span gets its own fresh span id, but it stores the incoming one as its parent.

The trace flags are small but important. The low bit usually carries the sampled decision. 01 means sampled. 00 means not sampled. That bit often determines whether downstream instrumentation exports full span data or keeps only local bookkeeping.

tracestate is optional and more implementation-specific. It exists so vendors or internal platforms can carry extra routing or sampling hints without changing the core standard. A tracing backend might store tenant routing, vendor-specific sampling priority, or internal metadata there.

There is also baggage. In OpenTelemetry and related systems, baggage is a separate context carrier for key-value pairs that should follow a request, such as tenant id, plan tier, or experiment cohort. Baggage is useful, but it is not the same as tracing identity. A trace can exist without baggage. Baggage can also become expensive or risky if teams stuff user data into it.

This is the first place where engineers get the wrong mental model. A trace is not born because you called a tracing API. A trace is born when a boundary either generates new trace context or continues existing context correctly.

If a request enters your edge proxy without any incoming context, the platform usually creates a new trace id and root span. If the request already carries valid context from an upstream caller, the edge should usually continue it instead of starting over. That choice decides whether the trace crosses organisational and infrastructural boundaries or stops at the edge.

The same logic repeats at every hop. Tracing is a context propagation protocol before it is an observability product.

A span is a timed operation with structure, not just a timer

Once context exists, individual components emit spans.

A span represents one operation with a start time, an end time or duration, a name, and enough metadata to place it inside a larger request graph. The graph matters because two 80 ms spans mean different things depending on whether one happened inside the other, after the other, or on a separate branch.

A practical span usually carries at least these fields:

  • trace id
  • span id
  • parent span id, unless it is a root span
  • operation name
  • start timestamp
  • end timestamp or duration
  • kind, such as server, client, producer, consumer, internal
  • attributes, such as HTTP method, route, peer service, database system
  • status, error flag, or recorded exception data

A simplified JSON-style span record for an orders service might look like this:

{
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "7a8c19d4e2f14011",
  "parentSpanId": "00f067aa0ba902b7",
  "name": "POST /checkout",
  "kind": "server",
  "startTimeUnixNano": 1781006400123456789,
  "endTimeUnixNano": 1781006400262456789,
  "attributes": {
    "http.request.method": "POST",
    "http.route": "/checkout",
    "service.name": "orders-api",
    "net.host.name": "orders-api.prod.internal"
  },
  "status": { "code": "OK" }
}

The field names vary slightly across libraries and storage formats, but the semantics are stable.

Three structural facts matter more than the exact schema.

First, spans are nested or linked, not merely listed. A server span for POST /checkout may contain child spans for validate basket, call payments, write order, and publish receipt job. That nesting is what turns a bag of timings into a request narrative.

Second, span kind affects interpretation. A client span usually measures an outbound request from the caller's point of view. A server span measures handling on the callee side. A producer span represents a message being published. A consumer span represents work started by receiving that message. If you ignore span kind, async flows become unreadable.

Third, spans are partial truth. A span only knows what the instrumented component saw. A client span can tell you when a caller started waiting for an HTTP response. It cannot tell you what SQL inside the downstream service caused the wait unless that downstream service emitted its own child spans. One span is a witness, not a judge.

This is also why trace trees vary in quality. A beautifully instrumented service will expose routing, database, cache, and queue details. A thinly instrumented service may emit only one coarse handle request span. Both belong to the same trace, but they do not provide the same explanatory power.

Propagation is the whole game

The hardest part of tracing is not span storage. It is keeping lineage intact when work crosses boundaries.

Consider a concrete request path:

Browser in Athens
  -> edge proxy in Frankfurt
  -> orders-api in Berlin
  -> payments-api in Sofia
  -> card gateway

Suppose the edge proxy creates a root server span because the browser did not send any trace context. It chooses trace id 4bf92f3577b34da6a3ce929d0e0e4736 and span id 00f067aa0ba902b7.

When the edge forwards the request to orders-api, it injects:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

orders-api extracts that header, creates a server span named POST /checkout, stores the incoming span id as its parent, and generates its own span id, say 7a8c19d4e2f14011.

Inside that handler, orders-api decides to call payments-api. Before it sends the outbound request, it creates a client span, perhaps c2d8f7a4131f0f44, as a child of the server span. Then it injects a new traceparent for the downstream call:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-c2d8f7a4131f0f44-01

payments-api receives that and creates its own server span as a child of the client span. The trace id stays constant across every hop. The parent span id changes at each handoff because each new hop should attach to the outbound span that caused it.

This is the basic rule for synchronous propagation:

  • preserve the trace id
  • set the parent to the current outbound span
  • create a new local span id at the receiver

If any service forgets to forward traceparent, the next service usually starts a fresh root trace. Nothing crashed. The business request still worked. But the trace graph is broken.

That is why tracing failures are often silent. A lost header does not look like an outage. It looks like a dashboard with an unexplained gap.

Async systems do not remove propagation requirements

Queues, streams, and job systems change the transport, not the requirement.

Suppose orders-api also publishes a receipt.send message for a worker in Warsaw. There is no HTTP request in flight anymore, so there is no header block in the usual sense. But the message still needs context fields.

A queue message might carry something like this:

{
  "messageId": "msg-918272",
  "topic": "receipt.send",
  "headers": {
    "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-8b31f1be66d94d33-01",
    "baggage": "tenant=retail,region=eu-central"
  },
  "body": {
    "orderId": "ord_48192",
    "email": "[email protected]"
  }
}

On the publisher side, the producer span represents the act of enqueueing. On the worker side, the consumer span represents starting work from that message. Some teams model the consumer as a child of the producer. Others add span links when batching or fan-in makes a strict tree misleading. The important point is that async tracing still needs explicit context carriage.

No carrier, no lineage.

Tracing libraries only work because they sit inside request lifecycle hooks

At this point people often ask, "If propagation is so important, why does it sometimes feel automatic?"

Because instrumentation libraries attach themselves to the framework points where requests are created, received, and completed.

In an HTTP server, middleware runs when a request arrives. That is the right place to extract context and open a server span. In an HTTP client, a wrapper around the outbound call is the right place to create a client span and inject context into headers. In a queue producer, publish functions get wrapped. In a consumer, message handlers get wrapped.

A trimmed OpenTelemetry-style example in TypeScript looks like this:

const tracer = trace.getTracer('orders-api')
 
app.post('/checkout', async (req, res) => {
  const extracted = propagation.extract(context.active(), req.headers)
 
  await context.with(extracted, async () => {
    await tracer.startActiveSpan('POST /checkout', { kind: SpanKind.SERVER }, async span => {
      try {
        await tracer.startActiveSpan('payments authorise', { kind: SpanKind.CLIENT }, async paySpan => {
          const headers: Record<string, string> = {}
          propagation.inject(context.active(), headers)
          await fetch('https://payments.internal/authorise', {
            method: 'POST',
            headers,
            body: JSON.stringify({ amount: 4900, currency: 'EUR' }),
          })
          paySpan.end()
        })
 
        span.end()
        res.status(200).send('ok')
      } catch (error) {
        span.recordException(error as Error)
        span.setStatus({ code: SpanStatusCode.ERROR })
        span.end()
        throw error
      }
    })
  })
})

The exact APIs differ between languages, but the mechanics are similar:

  1. extract context from the inbound carrier
  2. make that context current
  3. start a server span
  4. within that active context, start child spans for outbound work
  5. inject context into downstream carriers
  6. end spans when the work completes or fails

The key phrase there is "current context". Libraries need some way to know which trace is active on the current thread, coroutine, task, or event-loop continuation. In Go this is usually explicit context.Context. In Java it may be thread-local plus executor instrumentation. In Node.js it often relies on AsyncLocalStorage or framework patching. Context bugs are often really execution-model bugs.

If a callback escapes the tracked async context, the next span may attach to the wrong parent or no parent at all. That is why tracing quality can differ significantly between runtimes and library versions.

Collectors turn many partial reports into one usable dataset

Once services emit spans, something has to receive them.

A modern tracing pipeline usually looks like this:

instrumented process
  -> local SDK buffer
  -> exporter
  -> collector or agent
  -> storage backend
  -> query UI

The local SDK rarely sends each span synchronously at request time. That would be a good way to turn observability into production latency. Instead it batches spans in memory and exports them periodically or when buffers fill.

The exporter often speaks OTLP, the OpenTelemetry Protocol, usually over gRPC or HTTP. Some older stacks use Zipkin JSON, Jaeger thrift, or vendor-specific formats, but OTLP has become the common interchange path.

A collector sits between applications and the final backend. This layer matters more than many teams realise. A collector can:

  • receive OTLP from many services
  • batch spans to reduce network chatter
  • drop or sample spans
  • redact attributes
  • route data to different backends
  • convert formats
  • add resource metadata such as cluster or region

A minimal collector pipeline might look like this:

receivers:
  otlp:
    protocols:
      grpc:
      http:
 
processors:
  batch:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
 
exporters:
  otlp:
    endpoint: traces-backend.internal:4317
    tls:
      insecure: false
 
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp]

The collector does not reconstruct the full trace in a deep semantic sense. It mostly forwards span records onward. The backend or query layer later groups spans by trace id and parent relationships.

That grouping is why spans can arrive out of order and still produce a sensible trace. payments-api might flush its spans before orders-api does. The worker in Warsaw may export after the frontend in Frankfurt already finished. The backend does not need arrival order to match execution order. It needs stable identifiers.

This is one of the most useful mental shifts. A trace backend is not reading a wire capture of the request path. It is joining records after the fact.

That means backend query quality depends on three things:

  • consistent trace ids across hops
  • valid parent-child or linked relationships
  • enough timestamps and attributes to render the result sensibly

Without those, the UI cannot invent coherence for you. One practical detail often missed in diagrams is resource metadata. A span without stable resource fields such as service.name, deployment environment, region, or cluster origin is much harder to query during an incident. The trace may be structurally valid and still operationally awkward because the operator cannot quickly separate Berlin production traffic from a staging worker in Amsterdam.

Sampling decides which traces exist at all

If every request in a busy platform produced and stored every span, tracing costs would become unpleasant quickly.

Imagine a service graph where one external request touches eight services, each of which emits several spans for HTTP, cache, SQL, and queue work. At a modest few thousand requests per second, you are now generating an enormous telemetry stream. Storing every trace forever is usually unnecessary and often unaffordable.

Sampling is the mechanism that reduces volume.

Head sampling makes the decision early

Head sampling decides near the start of the trace, often at the first instrumented hop.

Typical rules include:

  • sample 1 percent of all requests
  • always sample errors
  • always sample requests for a staging tenant
  • sample high-latency requests above a threshold, if enough data is already known early

The operational advantage is simplicity. If the root says "not sampled", downstream services can avoid exporting most spans and save CPU, memory, and network bandwidth.

The weakness is obvious once you say it plainly: the decision happens before the full story is known. A request that looked normal at the edge may become the most interesting failure 400 ms later in a downstream dependency. If it was not sampled, the full trace is gone.

This is why some teams push for aggressive head sampling on low-value traffic and explicit keep rules for known-risk paths such as payments, signup, or reconciliation.

Tail sampling decides later, with more context

Tail sampling waits until enough of the trace has arrived to judge it.

A collector or backend may keep only traces that satisfy rules such as:

  • duration above 2 seconds
  • any span with error status
  • specific service or route names
  • traces touching a risky dependency
  • traces with too many retries

The advantage is better signal retention. The system can keep the ugly traces rather than guessing early.

The cost is state. Tail sampling needs somewhere to buffer partial traces until a decision is made. That means more memory pressure, more coordination, and more complexity in the collector tier.

In large systems the choice is often mixed: light head sampling to reduce sheer volume, combined with tail or rule-based retention for specific classes of important traces.

Sampling changes what your team believes

This is the subtle part.

When an engineer opens a trace view and sees nothing, the absence may mean several different things:

  • the request was never instrumented
  • propagation was broken
  • the trace existed but was not sampled
  • spans were emitted but dropped in the collector
  • storage or query indexing lagged behind

"No trace found" is not one explanation. It is a small failure tree.

Good observability teams make the sampling model explicit because otherwise operators confuse missing evidence with evidence of absence.

Time is messy, so backends trust structure more than perfectly aligned clocks

Tracing diagrams look like clean timelines, which encourages a dangerous assumption: that all participating systems share a precise global clock.

They do not.

Data centres in Frankfurt, Amsterdam, and Warsaw may all use NTP or PTP and still disagree by enough to create visible oddities in a fine-grained trace. Virtual machines can drift. Containers inherit host issues. Busy nodes delay scheduling. Some runtimes record timestamps with different precision. Some SDKs derive duration from a monotonic clock locally while still reporting wall-clock start times for storage.

This creates several familiar symptoms:

  • a child span appears to start slightly before its parent
  • a very short network call looks longer on the client than on the server
  • async consumer work appears far later than the producer span that caused it
  • cross-region traces show visually awkward gaps that are mostly clock and queue effects

Good tracing systems mitigate this in two ways.

First, they treat parent-child relationships as more authoritative than perfect timestamp ordering. If span B says its parent is A, the backend keeps that structural relationship even if the clocks are slightly awkward.

Second, they often rely on local duration measurement for each span. A service can usually measure its own 42 ms handler duration accurately enough, even if its wall clock is a few milliseconds away from the caller's clock.

This is why a trace waterfall is best read as a structured timing estimate, not a forensically perfect global timeline.

Clock skew becomes particularly confusing in async systems. A message may be published at 12:00:00.100 in Berlin and consumed at 12:00:00.080 according to a worker clock in Warsaw that is behind. The queue did not violate physics. The clocks disagree.

When traces become a production dependency, time discipline matters. NTP drift, broken node clocks, and timestamp precision mismatches can make a healthy trace look unhealthy.

Retries, queues, and fan-out make the trace graph messier than the happy-path tree

The neatest traces come from straight-line request paths. Real systems are rarely that kind.

Suppose orders-api calls payments-api, times out after 250 ms, retries once, and the second attempt succeeds. There are several honest ways to represent this:

  • one parent server span in orders-api
  • two child client spans, one per attempt
  • two corresponding server spans in payments-api, if both attempts reached it
  • attributes that mark attempt number, timeout reason, and retry policy

What you should not do is collapse the two attempts into one vague call payments span with no retry detail. That hides the mechanism that made the request slow.

Fan-out creates another structural problem. A request may call catalogue, pricing, stock, and promotions in parallel. The parent span then has several siblings under it. The total request time is not the sum of every child duration because some ran concurrently.

Batch systems are worse. One consumer may process 200 messages in one loop. Should there be one consumer span for the batch, 200 child spans, or 200 linked traces carried through message metadata? The answer depends on whether the operation is operationally one unit or logically many units. Tracing data models can represent several relationships, but the instrumentor has to choose consciously.

This is where span links become important. Parent-child is for direct causality in the active execution tree. Links are better when work is related but not well described as one strict tree, such as batch fan-in, asynchronous joins, or derived jobs started from several upstream triggers.

Queues also introduce waiting time that does not belong to either the producer's CPU work or the consumer's processing time alone. Some teams record enqueue-to-dequeue delay as attributes or derived metrics because the trace graph itself does not automatically explain queue age.

The broader lesson is that tracing is not a toy tree of synchronous RPC calls. As soon as retries, async jobs, and parallel branches enter the architecture, trace shape starts encoding design decisions.

Automatic instrumentation is useful, but it does not know your business boundary

Auto-instrumentation is one of the reasons tracing became broadly adoptable. Agents and libraries can hook common frameworks, HTTP servers, HTTP clients, database drivers, and message brokers with little code.

That gives you a fast baseline. It does not give you a complete observability model.

An auto-instrumented service usually knows:

  • an inbound HTTP request arrived
  • an outbound HTTP or gRPC request happened
  • a SQL query executed
  • a Redis command ran
  • a message was published or consumed

It usually does not know:

  • which business workflow step this operation represented
  • whether a retry was user-safe or dangerous
  • which order id or tenant should be searchable later
  • whether a slow query was expected because a batch window was open
  • whether the operation crossed a compliance or money-movement boundary

If every span in your trace is named HTTP POST or SELECT, the trace may technically exist but operationally say very little.

Manual instrumentation is how teams add the missing semantics. A good manually named span might be:

  • authorise card
  • reserve stock
  • load VAT rules
  • publish receipt.send
  • reconcile settlement batch

Those names make traces explain the work, not just the transport.

Attributes matter too, but they need discipline. High-cardinality values such as raw email addresses, full URLs with query strings, or arbitrary payload fragments can explode storage cost and leak sensitive data. Good span attributes are stable, useful, and bounded.

Common examples:

  • http.route=/checkout
  • messaging.destination=receipt.send
  • db.system=postgresql
  • retry.attempt=2
  • payment.provider=adyen

Bad examples include dumping the whole basket JSON, card PAN fragments, or one-off error blobs that should have stayed in redacted logs.

This is where traces and logs should cooperate. The trace carries structure and timing. Logs can carry richer local detail. The bridge is correlation. If logs include the current trace id and span id, you can jump from a suspicious span to the local evidence without making the trace record itself too heavy or too sensitive.

What a good trace is actually used for during an incident

A trace is useful when it shortens the path from symptom to mechanism.

Suppose checkout latency in Europe rose only for card payments above 500 euros. Metrics tell you p95 increased. Logs tell you some payment provider calls were slow. A good trace lets you answer the operational questions that matter next:

  • did the slowness begin at the edge, in orders, or in payments
  • was the bad path synchronous or queue-backed
  • did every slow request hit the same downstream provider
  • were there retries, and if so where
  • did a proxy, service mesh, or gateway add significant overhead
  • are the slow traces concentrated in one region or dependency

A backend query often starts with coarse filters:

  • service name
  • route name
  • status code or error flag
  • duration threshold
  • region or deployment version

Then the operator opens a representative trace and inspects the critical path. The critical path is the sequence of dependent work that actually determined end-to-end latency. Not every span on the page matters equally.

This is why tracing backends highlight longest spans, error spans, and dependency breakdowns. The goal is not to admire the graph. The goal is to narrow blame responsibly.

Good traces also help with questions that logs answer poorly:

  • did the request take the new canary path or the stable path
  • did the retry happen at the client library, service mesh, or application layer
  • did the worker processing belong to the same original request or a separate job family
  • did the backend failure happen before or after the caller timed out

There is also a political value to tracing inside organisations. Shared traces reduce unproductive blame because several teams can look at the same request lineage instead of arguing from disconnected logs.

That benefit disappears if the trace breaks at team boundaries. Which brings us to the most practical part of the topic.

The most common reasons traces lie or disappear

Tracing failures are usually boring in mechanism and expensive in effect.

1. Propagation was dropped at one boundary

This is the classic case. A service extracted inbound context but forgot to inject outbound context. Or an API gateway stripped non-allowlisted headers. Or a queue wrapper did not copy message headers. Result: the next hop starts a fresh root trace.

2. Context escaped the runtime model

A callback ran outside the tracked async context. A goroutine lost the parent context.Context. A Java executor was not instrumented. A Node.js promise chain broke AsyncLocalStorage continuity. Result: spans get attached to the wrong parent or no parent.

3. Sampling hid the request

The request existed but was never retained. Operators often misread this as an instrumentation gap when it is really a retention policy outcome.

4. Proxy and application spans tell different stories

A service mesh, ingress proxy, or load balancer may emit spans around the same traffic the application also traced. That is useful, but it can confuse teams if names, timing windows, and service identity differ. You need to know whether a span came from the app, the sidecar, the gateway, or the collector enrichment layer.

5. Retries were flattened away

A single span called call payments may hide three attempts. That destroys the diagnostic value of the trace because the latency mechanism was the retry pattern itself.

6. Sensitive data leaked into attributes or baggage

This is a governance failure more than a tracing failure, but it matters. Traces often flow into shared tools with broad read access. If one team puts raw personal data into span attributes, the observability platform becomes a data-handling problem.

7. Cardinality exploded

If span names or attributes include unbounded values such as user ids, full URLs, or random job keys, query indexes become expensive and dashboards become noisy. Good instrumentation prefers route templates and bounded labels.

8. Teams trusted the pretty graph more than the underlying fields

A trace UI is a projection. When the graph looks strange, inspect the raw span metadata:

  • trace id
  • span id
  • parent span id
  • kind
  • timestamps
  • service name
  • resource attributes
  • trace flags

Most tracing mysteries stop being mysterious once you read those fields directly.

Building a tracing model that survives real systems

Teams do not need perfect traces. They need traces that remain honest under normal complexity.

In practice that means a few engineering rules pay for themselves quickly.

First, standardise on one propagation format across the fleet. W3C Trace Context is the obvious default because most modern tooling understands it.

Second, instrument every boundary where control leaves one component and enters another:

  • inbound HTTP or gRPC
  • outbound HTTP or gRPC
  • database calls that matter to latency
  • cache calls that matter to latency
  • message publish and consume
  • background job start and completion

Third, add manual spans at business boundaries, not everywhere. Too many low-value spans create noise. Too few high-value spans create blind spots.

Fourth, make logs trace-aware. A structured log line with trace_id and span_id is still one of the most practical debugging tools in production.

Fifth, decide sampling rules with incident response in mind. Sampling is not just a storage-cost choice. It determines which classes of failure you will be able to investigate later.

Sixth, treat collector infrastructure as production data plane, not side furniture. If collectors back up, drop data, or misroute telemetry, your incident tooling degrades exactly when you need it.

Seventh, review trace data for privacy. The cheapest time to prevent sensitive-data leakage is before teams start stuffing payloads into attributes.

Finally, teach operators how to read traces structurally. The most useful questions are usually simple:

  • where was the root span created
  • which trace id should this request have carried
  • where did parent-child continuity break
  • which spans were on the critical path
  • what was sampled, dropped, retried, or linked

These are engineering questions, not dashboard questions.

The smallest honest summary

Distributed tracing works by carrying a small piece of request identity across component boundaries and having each component emit timed span records that preserve parent-child or linked relationships.

Everything else is implementation detail around that core: SDK hooks, async context management, propagation headers, queue metadata, collectors, sampling, storage, and query tooling.

When tracing works well, it gives you something logs and metrics cannot provide alone: a structured lineage for one request across many systems.

When tracing works badly, the dashboard still looks professional. It just tells a broken story.

That is why the real engineering work is not drawing waterfalls. It is preserving truth at the boundaries where one system hands work to another.