Hey everyone, welcome to the forty-seventh issue of The Main Thread.

Consider a scenario: checkout latency triples on a Tuesday afternoon, the gateway p99 jumps from 400ms to 1.8s. Auth is fine, cart is fine, pricing is fine, payments are fine. The user is waiting almost 2s and every component involved claims innocence.

This is normal, and it follows from arithmetic: percentiles don’t compose. A request touching 50 services collects latency from each hop, and slow edge requests are usually assembled from moments that look unremarkable inside any single service. Metrics tell us the system is slow; they aggregate away the identity of the request, so they cannot tell us where one slow request spent its time. Logs have the opposite problem: 50 services produce 50 disconnected narratives with no shared key and no causality we can trust across hosts.

Distributed tracing exists to answer exactly this question: for one specific request, reconstruct the full tree of work it caused across every service, with timing. Google published the design in the 2010 Dapper paper, Zipkin and Jaeger carried it into open source, and the ecosystem consolidated into OpenTelemetry in 2019. The ideas stabilized a decade ago; what still goes wrong is the plumbing: context that fails to propagate, spans that record the wrong things, sampling that discards the traces we needed, and bills that grow faster than traffic.

This issue covers the whole path: how trace context moves between services (W3C and B3, byte by byte), how to design spans worth reading, how to sample when we cannot keep everything, how the OpenTelemetry pieces fit in production, how to read the trace shapes that diagnose most incidents, and how to keep the bill sane.

The Anatomy of a Trace

A trace is the record of one request’s journey through the system. A span is one named, timed unit of work inside it: an HTTP handler, a database query, a call to a payment provider. Spans form a tree; each span knows its parent, and the root (the edge request) has none. That is the entire data model, unchanged since Dapper.

The slow checkout, rendered as a waterfall in every tracing UI, looks like this:

Distributed request trace

One picture ends the incident call. Auth, cart, and pricing are collectively responsible for under 500ms. The payment provider took 1.3s, and every service reporting a healthy p99 was telling the truth: the time went to an external dependency no per-service dashboard owns.

Each span creates a fixed set of fields:

Identity

A trace_id (16 bytes, shared by every span in the trace), a span_id (8 bytes), and the parent’s span_id. These three make the tree reconstructable from spans that arrive out of order, from different hosts, minutes apart.

Timing

Start and end timestamps, taken on the host doing the work.

A name

POST /checkout, SELECT discounts. Low cardinality by convention: the route template, never the concrete URL with IDs in it.

Attributes

Key-value metadata about this execution. (http.response.status_code = 402, cart.item_count = 17).

Events

Timestamped point-in-time annotations inside the span, most importantly recorded exceptions.

Status

OK or Error.

A span is a structured log line with a duration and a family tree: anything we would have logged during a request can live on a span instead. pre-joined to everything else that happened to that request.

One more field earns its keep: SpanKind. A cross-service call produces two spans, a CLIENT span in the caller and a SERVER span in the callee, and the gap between them is network time plus queueing, computable only because the kinds are marked. PRODUCER and CONSUMER play the same role across queues; INTERNAL marks everything else.

Context Propagation: The Actual Hard Problem

Tree reconstruction depends on one thing: when service A calls service B, B must learn A’s trace_id and span_id. Nothing about HTTP, gRPC, or Kafka carries the identifiers: every service must read them on the way in and write them on the way out, and this relay race is where tracing deployments actually fail. Drop the baton at one hop and every span downstream starts a fresh trace, orphaned from the request that caused it.

W3C format

The traceparent header is the standard, a W3C recommendation since 2020 and OpenTelemetry’s default. One line, four dash-separated fields:

W3C format

Each service extracts this header, parents its own span under parent-id and injects a new traceparent into every outbound call with its own current span as the new parent-id. The trace-id never changes; the parent-id changes at every hop.

The last byte (sampled flag) carries the sampling decision down the chain, so one decision at the edge governs the whole trace. This one byte makes head sampling cheap, and it is also head sampling’s built-in limitation; the sampling section returns to it. A second header tracestate, rides alongside as vendor-specific key-value pairs; we rarely touch it, we always forward it.

B3 format

Before W3C standardization, B3 was the de facto format, from Zipkin (the name comes from BigBrotherBird, Zipkin’s original codename). The same information is spread across headers.

X-B3-TraceId:      4bf92f3577b34da6a3ce929d0e0e4736
X-B3-SpanId:       00f067aa0ba902b7
X-B3-ParentSpanId: 0020000000000001
X-B3-Sampled:      1

or packed into one: b3: {trace-id}-{span-id}-{sampled}. B3 is everywhere in older infrastructure: Istio, Linkerd, Spring Cloud Sleuth, anything Zipkin-era. A fleet where half the services speak traceparent and half speak B3 breaks traces at every boundary between the camps. OpenTelemetry’s composite propagators fix the migration: extract whichever format arrives, inject both on the way out, converge on W3C, then drop B3.

Where propagation actually breaks

The header mechanics are trivial. The failures are structural, and they cluster in four places:

1. In-process async boundaries

Context lives in a thread-local or a context variable. Hand work to a raw thread pool and the context stays behind on the submitting thread; spans created inside the pool silently start new traces. Instrumented executors exist for exactly this; the danger zone is custom concurrency code.

2. Message queues

HTTP auto-instrumentation forwards headers for us. A Kafka producer does not, unless instrumentation injects traceparent into the message headers and the consumer extracts it. If we miss this, then every trace ends at the producer. The queue decouples our services, and left alone, decouples our traces too.

3. The one uninstrumented hop

A proxy that strips unknown headers, a serverless function without the SDK, a service in a language nobody instrumented. One opaque hop cuts the tree. The telltale signature in the backend: a population of traces that all start at an internal service that should never be a root.

4. Batch consumers

One consumer span processes 500 messages from 300 different traces. A parent-child edge is wrong here (which parent?). Span links exist for this: the consumer span links to all 300 producer contexts without claiming descent from any.

For a queue, the propagation we otherwise get for free has to be spelled out:

from opentelemetry.propagate import inject, extract
from opentelemetry import trace

tracer = trace.get_tracer("orders")

# Producer: write the current trace context into the message headers.
def publish(producer, order_event):
    headers = {}
    inject(headers)  # adds 'traceparent' (and tracestate)
    producer.send(
        "orders", value=order_event,
        headers=[(k, v.encode()) for k, v in headers.items()],
    )

# Consumer: pull the context back out and parent the processing span to it.
def handle(record):
    ctx = extract({k: v.decode() for k, v in record.headers})
    with tracer.start_as_current_span("process_order", context=ctx):
        process(record)
    # The trace now crosses the queue: producer span -> this span.

One more thing travels with the trace: baggage, a separate W3C spec for arbitrary key-value pairs propagated hop to hop (baggage: tenant.id=acme,deploy.canary=true). It lets a service deep in the stack know something only the edge knew (canary attribution, tenant-level debugging), and it is a footgun: every hop pays its size on the wire, and every hop can read it. Routing-grade facts go in; secrets and PII stay out.

Span Design: What to Capture and When

Instrumentation quality decides whether traces answer questions or merely exist. The failure modes sit at both extremes: spans so sparse that waterfall shows shapes with no explanations, and a span per function call, drowning the signal and quadrupling the bill.

A good granularity rule is: a span for every network boundary, plus a span for each internal unit we would want as its own bar in the waterfall. Network hops are non-negotiable; latency and failure concentrate there. Internal spans have a concrete test: if this unit were slow, would knowing that change where we look next? Serialization of a 40MB response, a lock acquisition: yes. A utility function: no.

Everything else is choosing among the three places a fact can live:

The fact

Where it goes

Why

Has meaningful duration

Child span

We want it as a bar in the waterfall

Point-in-time occurrence

Event

Timestamped marker inside the span (exception, cache miss, retry attempt)

Key-value context about the execution

Attribute

Filterable, groupable metadata

Two decisions inside the above table have the most weight.

Use the semantic conventions.

OpenTelemetry standardizes attribute names: http.request.method, http.response.status_code, url.path, db.system. Backends key their latency breakdowns, error rates, and service maps off these exact names; invent myapp.status instead and those features degrade to a generic list. Custom names are for domain facts (cart.item_count, payment.provider, tenant.id).

Metrics forbid user-ID labels because each value mints a new time series. A span attribute is a field on one record: user.id and order.id belong on spans, and they are exactly what makes tracing useful during an incident (“show me traces for the customer in this ticket”). Cardinality still hurts in the span name, which backends group by: name the span GET /users/{id}, put the ID in an attribute.

Instrumented by hand, a unit of work worth tracing looks like this:

from opentelemetry import trace
from opentelemetry.trace import StatusCode

tracer = trace.get_tracer("pricing-service")

def compute_totals(cart, user):
    with tracer.start_as_current_span("compute_totals") as span:
        # Attributes: facts we will filter and group by later.
        span.set_attribute("cart.item_count", len(cart.items))
        span.set_attribute("user.id", user.id)
        span.set_attribute("pricing.rule_set", cart.rule_set_version)

        discounts = load_discounts(cart)      # auto-instrumented DB client
                                              # creates the child span itself
        if discounts.cache_miss:
            # Event: a point-in-time occurrence, timestamped within the span.
            span.add_event("discount_cache_miss", {"cache.key": cart.region})

        try:
            return apply_rules(cart, discounts)
        except RuleEngineError as e:
            # Both halves matter: the event carries the stack trace,
            # the status makes the span findable as an error.
            span.record_exception(e)
            span.set_status(StatusCode.ERROR, str(e))
            raise

Error handling has one subtlety: span status is about this operation's contract, and it does not automatically follow HTTP codes. A GET /users/23 returning 404 during an existence check did its job; marking it ERROR floods the error views with non-errors. Auto-instrumentation follows the conventions' defaults (5xx is an error on servers, 4xx and 5xx on clients); override deliberately where the semantics differ.

What stays off the span: request and response bodies, SQL with literal values inlined, authorization headers, anything a privacy review would flag. Traces land in a system with looser access control than the production database and persist for the retention window. The collector can scrub centrally; restraint at the source is still the policy.

Sampling: Deciding What to Keep

Let’s see the arithmetic that dominates every deployment at scale. A mid-sized system: 2000 requests/sec at the edge, around 50 spans per request. That is 100K spans/second. 8.6 billion per day, and at a representative 500 bytes per encoded span, roughly 4.3 TB per day before replication or indexing. Dapper shipped with 1-in-1024 sampling in 2008 for exactly this reason. Sampling is unavoidable; the only real question is where the decision gets made.

Head sampling: decide at the root

The root service flips a coin when the trace starts, records the outcome in the traceparent sampled flag, and every downstream service honors it:

from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased

# Root spans: keep 1%. Non-root spans: obey the incoming sampled flag.
# ParentBased is what makes the decision consistent across the fleet;
# without it, each service samples independently and we get partial traces.
sampler = ParentBased(root=TraceIdRatioBased(0.01))

Head sampling is cheap (unsampled traces cost almost nothing at runtime) and operationally trivial. Its flaw is fundamental: the decision is made before anything has happened. At 1%, we keep 1% of the errors, 1% of the 8-second outliers, 1% of the traces from the customer in the support ticket. The traces we want are rare by definition, and a uniform coin flip is uniformly blind to them.

Trace sampling: decide once the trace is complete

Every span ships to a collector, which buffers spans by trace ID, waits for the trace to finish, then applies policy with full knowledge of what happened:

processors:
  tail_sampling:
    decision_wait: 10s # buffer window before judging a trace
    policies:
      - name: keep-errors
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: keep-slow
        type: latency
        latency: {threshold_ms: 2000}
      - name: baseline
        type: probabilistic
        probabilistic: {sampling_percentage: 1}

The result is close to ideal. The costs are concrete:

1/ We pay to generate and transport every span, kept or discarded. The savings apply to storage and ingest only.
2/ All spans of a trace must reach the same collector instance, or policies judge fragments. This forces trace-ID-aware routing (the collector's loadbalancing exporter with routing_key: traceID) in front of the sampling tier.
3/ The buffer is state. 10 seconds of decision_wait at 100,000 spans per second is a million spans held in memory per second of window, and a trace outliving the window is judged incomplete.

Head sampling

Tail sampling

Decision point

Trace start, at the root

Trace end, at a collector

Sees errors/latency?

No, blind coin flip

Yes, full trace visible

Runtime cost

Near zero for unsampled

Every span generated and shipped

Infrastructure

None beyond the SDK

Buffering tier + trace-ID routing

Failure mode

The trace we needed was dropped

Collector memory pressure, split traces

The pragmatic answer at real size is layered: head sampling in the SDK at a rate generous enough to feed the tail tier, tail sampling at the collector for the keep/drop intelligence, rate limits as a circuit breaker so a traffic spike degrades sampling instead of collectors. And because the flag rides in traceparent, use ParentBased samplers everywhere so what survives is complete traces. Partial traces are worse than fewer traces.

OpenTelemetry Pipeline

OpenTelemetry won: OpenTracing and OpenCensus merged into it in 2019, every major vendor accepts its protocol (OTLP), and instrumenting against anything else in 2026 is a legacy decision. The architecture is more important than any individual API.

The API/SDK split is the load-bearing design decision. Libraries (the HTTP framework, the Postgres driver) instrument against the OpenTelemetry API, a facade of no-ops. Applications install the SDK, which makes the facade real and decides exporters, samplers, and resource attributes. This is why a library can ship instrumentation without forcing an observability stack on its users, and why auto-instrumentation works at all.

Auto-instrumentation covers the plumbing; manual covers the business. The Java agent and Python's opentelemetry-instrument wrapper patch the common clients and servers at load time: every inbound request gets a SERVER span, every outbound HTTP/DB/queue call gets a CLIENT span, propagation handled. That alone produces the waterfall skeleton; the manual layer (the compute_totals example) adds what the request means. Deploy it fleet-wide first; it is the highest-value single step.

Applications export to a Collector, never directly to the backend:

The reasons to insist on this shape:

1/ Vendor decisions become config. Switching backends, or running two during an evaluation, is an exporter stanza in one place instead of 50 repos.
2/ Policy is centralized. Tail sampling, PII scrubbing, and attribute trimming happen once, at the gateway.
3/ The app is insulated. Batching, compression, retry with backoff, and queue-on-outage live in the Collector. A vendor incident stops being a memory leak in our checkout path.

The last line of the diagram ties this to cost: the spanmetrics connector derives request/error/duration metrics from spans before sampling throws data away. Aggregates come from 100% of traffic; full traces are retained for 1%. Accurate percentiles and cheap storage stop being a trade-off.

Reading Traces

Waterfalls repeat a small vocabulary of shapes, and knowing them converts a wall of bars into a diagnosis. There are three of them that cover a remarkable share of production incidents.

The staircase

Each call starts where the previous one ended: the N+1 query problem promoted to the network. Nothing here is slow: the shape is slow, 12 hops at 100ms each. The fix is a batch endpoint or concurrent fan-out, and the trace even prices the fix: batched, this page costs ~110ms instead of 1240ms. Staircases hide inside ORMs, per-item enrichment loops, and sequential awaits nobody meant to serialize. On a dashboard this pathology is invisible; product-service p99 is a flawless 105 ms.

The gap

Time inside a span that no child accounts for is the gap. The service was doing something for 613 ms, and nobody instrumented it. Gaps are where tracing hands off to Brendan Gregg's territory, because a gap is almost always off-CPU time: the request was waiting, and waiting is invisible to instrumentation that only wraps calls.

The recurring culprits, in rough order of frequency, are: connection-pool acquisition, garbage-collection pauses, lock contention, synchronous DNS or TLS setup on a cold path, CPU starvation on a throttled host.

Each has a next move: a span around pool acquisition (the single highest-value manual span in most codebases), correlating the gap's timestamp with runtime GC metrics, or a profiler once request-scope data is exhausted. The gap does not name the culprit; it scopes the search to one service and 613 specific milliseconds.

The retry storm

So, we have three identical child spans, two failed. The client library retried, silently, with a 3-second timeout per attempt, and the caller experienced 9.3 seconds. Retries configured in a client library are invisible in code review and in metrics (the dependency's error rate counts attempts, so it can even look better while callers suffer).

In a trace they are three unmistakable bars, and they raise the right questions: why does the first attempt time out, is the retry budget sane against the caller's own deadline, is there backoff and jitter. Multiply this shape down a 5-deep call chain with 3 retries per hop and the bottom sees 3^5 = 243 requests for 1 at the top.

One caveat applies to all careful trace reading: timestamps come from different hosts, so clock skew is real. NTP-synced fleets sit within a few milliseconds, fine for waterfalls, useless for microsecond forensics. A child span apparently starting before its parent is skew. Within one host, timestamps share a clock and can be trusted.

Cost: Keeping the Bill Proportional to Value

Let’s return to the arithmetic: ~4.3 TB per day at 100% capture for a 2,000 rps system. At an illustrative $0.20 per ingested GB, that’s roughly $26,000 per month, scaling with traffic times instrumentation density, both of which only grow.

The levers that we can pull, in order:

1/ Sampling is the primary lever, and tail sampling makes it safe. The keep-errors, keep-slow, 1%-baseline policy cuts ingest by ~99% while retaining nearly every trace anyone will look at: the difference between $26,000 and a few hundred dollars a month, at the price of a collector tier costing an order of magnitude less than the ingest it saves.

2/ Derive metrics before sampling. The spanmetrics pattern, stated as economics: dashboards and alerts consume aggregates daily, and aggregates compress millions of spans into a handful of time series. Compute them from the full stream at the collector, then sample the traces.

3/ Trim at the collector. A 2 KB SQL text repeated on a million spans is 2 GB of the same string. Truncate long values, drop attributes no one queries, and drop whole span populations with no diagnostic value: health checks alone add millions of identical, worthless spans a day. Dropped at the agent, they never cross the network.

4/ Tier the retention. Trace value decays in hours: debugging happens against the last day, incident review against the last two weeks. Baseline retention of 7 days with 30 for error traces covers nearly every real access pattern at a fraction of flat 30-day retention. Aggregate trends are what the metrics from lever 2 are for.

5/ If self-hosting, choose object-storage-backed designs. Grafana Tempo bets that traces are fetched by ID far more often than searched, and stores compressed blocks at S3 prices. ClickHouse-backed stores make search cheap through columnar compression. Elasticsearch-era per-span indexing is the expensive ancestor; if a Jaeger deployment still writes to it, a 10x reduction is sitting there.

Takeaway

Metrics aggregate across requests. Logs fragment within them. A trace is the one artifact keyed by the request itself, which is why it answers the question the other two cannot: for this specific slow request, where did the time go. Everything in this issue serves that artifact's integrity end to end: propagation keeps the tree connected across 50 services and every queue between them, span design makes each node worth reading, sampling decides which trees survive, and the collector pipeline is where policy and economics live.

The failure mode to defend against is quiet degradation. Tracing rarely breaks loudly; it decays. A queue hop loses context and traces silently shorten. Head sampling at 1% quietly discards incident traces for months before anyone needs one and finds it missing. So treat the trace pipeline like the production system it is, and start this week: pull up 10 traces for the highest-traffic endpoint and read them against the three shapes. A staircase that should be a batch call, a 600 ms gap nobody instrumented, a retry hiding in a client library. One of them is in there, and it has been on every dashboard all along, averaged into invisibility.

If this clicked, forward it to whoever is currently grepping 50 services' logs for one request. And if you want more deep dives like this, subscribe to The Main Thread: practical distributed systems engineering, one essay per week.

Namaste!

Reply

Avatar

or to participate