Notes / System Design / 15 Complete Case Studies / 01 Telemetry Ingestion Pipeline

Head vs. Tail Sampling for Distributed Traces

The sampling decision point determines whether a trace pipeline needs to buffer spans in memory — head-based decides at trace start, tail-based decides after the trace completes.

Appears in: Telemetry Ingestion Pipeline §3.3 (trace processor deep dive), §5 (head vs. tail trade-off).

Every distributed trace produces far more spans than anyone can afford to store. The question that shapes the entire trace pipeline is: when do you decide which traces to keep?


The core distinction

Head-basedTail-based
Decision pointAt trace start — before any span outcome is knownAfter the trace completes — root span has arrived
Decision made bySDK or agent, independent per serviceA central span assembler holding the whole trace
Memory requirementNone — stateless, decide and forwardMust buffer every span until the trace resolves
Can bias toward errors?No — the outcome doesn’t exist yetYes — that’s the entire point
Operational complexityLowHigh — span assembly, TTL/orphan handling, memory pressure

Head-based sampling

The decision is made the moment a trace starts, typically at the root span, and propagated to every downstream span via the trace context (sampled flag in the W3C traceparent header). Every service in the call chain honors the same decision — this is consistent or parent-based sampling, and it’s what makes head-based sampling viable at all: without propagation, each service would sample independently and you’d get fragments instead of complete traces.

sequenceDiagram
    participant A as Service A (root)
    participant B as Service B
    participant C as Service C

    Note over A: Decide NOW: sample? (e.g. 1% roll)
    A->>B: traceparent (sampled=true)
    Note over B: Honor parent's decision — no new roll
    B->>C: traceparent (sampled=true)
    Note over C: Honor parent's decision

Strength: simple, stateless, zero memory overhead — the decision is made and forgotten immediately, no span ever needs to be held waiting for a verdict.

Weakness: blind. A 1% head-sample rate means you keep 1% of everything, uniformly — including the boring, healthy 99% and missing the interesting slow/errored 1% at the same uniform rate. You cannot bias toward “traces I actually care about” because you don’t yet know which traces those will be.

Tail-based sampling

The decision waits until the trace is (believed to be) complete — i.e., the root span has arrived — and then applies a policy that can actually look at the outcome: did it error, was it slow, did it touch a specific service.

flowchart TD
    A["Spans arrive\npartitioned by trace_id"] --> B["Span assembler\nholds spans in memory or Redis"]
    B --> C{"Root span\narrived?"}
    C -->|"Still waiting"| B
    C -->|"Yes — evaluate policy"| D{"Sampling\ndecision"}
    D -->|"Error or latency > P99"| SAM["Write to Tempo"]
    D -->|"Healthy trace"| RATE{"Within 1%\nsample rate?"}
    RATE -->|Yes| SAM
    RATE -->|No| DROP["Drop"]
    B -->|"TTL expired — no root span"| ORPHAN["Orphan flush\nlow-rate sample or drop"]

Strength: intelligent. You can write a policy like “keep every errored trace, keep every trace slower than P99, keep 1% of everything else” — which is what you actually want operationally: full visibility into anomalies, a representative sample of the baseline.

Weakness: the span assembler is a genuinely hard distributed systems problem. Every span of a trace has to land on the same worker (hash-partition by trace_id), that worker has to hold every span in memory until the root arrives, and at high trace volume that memory footprint is substantial. A trace that’s still open (a slow trace — exactly the kind you most want to keep) sits in memory the longest, which creates the failure mode covered in Q3: trace pipeline redesign under incident load: a memory-pressured LRU evicts the oldest open traces first, and those are disproportionately the slow ones you were trying to catch.

1% of everything

Here’s what it means concretely:

  • Errors and slow traces are always kept (100% retention) — these are the traces you actually care about operationally.
  • Everything else — the healthy, fast, boring traces that didn’t trip any policy — you don’t need all of them. You just need a representative random sample to know what “normal” looks like (baseline latency distributions, typical span counts, etc.), without paying to store every single healthy request.
  • “1% of everything” = a flat 1% probabilistic sample applied uniformly across that leftover bucket. In the YAML below it (line 105-107):
- name: baseline
  type: probabilistic
  sampling_percentage: 1

that’s the literal implementation — a coin flip that keeps 1 in 100 traces from the “nothing interesting happened” bucket.

Why this matters: it’s the whole point of tail-based sampling over head-based. With head-based sampling (decided before the outcome is known), you’re forced into one uniform rate for all traces — so “1% of everything” would mean you also lose 99% of your errors and slow traces, which is the opposite of what you want. Tail-based sampling lets you say “keep 100% of the interesting 1%, and only apply that cheap 1%-of-everything sampling to the boring 99%” — full anomaly visibility + a cost-bounded baseline, instead of one flat rate applied blindly to both.

That’s also why the doc calls head-based sampling “blind” at line 51-54 — its 1% rate has no way to distinguish an error from a healthy request, so it throws away the interesting cases at the same rate as everything else.

Composite policies (what tail sampling actually looks like in practice)

Real tail-sampling configuration combines multiple policies with OR semantics — keep the trace if any policy says keep:

processors:
  tail_sampling:
    decision_wait: 10s          # how long to wait for the root span before giving up
    num_traces: 500000          # bound on concurrently-open traces (memory cap)
    policies:
      - name: errors
        type: status_code
        status_codes: [ERROR]
      - name: slow
        type: latency
        threshold_ms: 500
      - name: baseline
        type: probabilistic
        sampling_percentage: 1

decision_wait and num_traces are the two knobs that directly trade memory for completeness — wait longer and you catch more slow traces, but hold more open traces in memory at once.

Orphan handling

Not every trace’s root span arrives before the TTL(Time To Live) — a service crash mid-request, a lost span, a misconfigured SDK. The assembler must decide what to do with a trace that times out with no root: sample it at a low rate (some visibility into orphans, which are themselves often a symptom of a problem) or drop it outright. Silently holding orphans forever is not an option — it’s an unbounded memory leak disguised as a sampling policy.

TTL(Time To Live)/orphan handling is the operational overhead specific to tail-based sampling — the price you pay for waiting until a trace completes before deciding whether to keep it.

Here’s the mechanism, tied to the flow in the note’s mermaid diagram (line 62-70):

  • A tail-based span assembler holds every span for a trace_id in memory (or Redis) until the root span arrives, at which point it can evaluate the sampling policy (error? slow? keep it).
  • But not every trace’s root span ever arrives — a service crashes mid-request, a span gets dropped in transit, an SDK is misconfigured. Without a cutoff, the assembler would hold that trace’s spans forever, waiting for a root span that’s never coming — an unbounded memory leak.
  • TTL is that cutoff: a timer (the note’s example config uses decision_wait) after which the assembler gives up waiting for the root span.
  • Orphan handling is the policy for what to do with a trace that hits the TTL with no root span — an “orphan.” The two choices are: sample it at a low rate anyway (orphans are often themselves a symptom of a problem worth some visibility into) or drop it outright.

Which to choose

Use caseRecommendation
Business-critical services (checkout, auth, payments)Tail-based — you need the errored/slow traces specifically
Internal infrastructure services (high volume, low variance)Head-based at a higher flat rate (e.g. 10%) — aggregate rates are what you actually query
Both at the same layerNever — it multiplies complexity for no benefit; pick one per service class

This mirrors the trade-off table in the main design’s §5 trade-offs: tail-based for anything where you’d page someone over what it catches, head-based everywhere else.


Local graph

Full graph →

Linked from 9 notes

Chapter 2 — Telemetry Pipelines

OpenTelemetry, OTLP, collectors, sampling, and aggregation as the pipeline that gets a signal from emission to storage without becoming the outage itself.

3.3 Layer 3: Processing / Enrichment

Layer 3 of the telemetry ingestion pipeline: processing and enrichment — the metric processor, cardinality enforcement, tail-based sampling, the log processor, metric temporality, and Kubernetes metadata enrichment.

5. Trade-offs at 10x Scale

The 'what would you do differently at 10x' trade-off questions for the telemetry ingestion pipeline: Kafka vs. direct write, trace-assembly sharding, schema-on-read vs. write, sampling strategy, protocol choice, and push vs. pull.

Q3: Trace Sampling Loses Spans During Incident Peaks — Redesign

Full principal-level solution: the trace pipeline drops spans exactly when incidents spike trace volume — diagnose the failure mode and redesign the tail-sampling pipeline to survive it.

2 — The Signals

Metrics, logs, traces, profiles, and events — what each is built to capture, what it costs, and which question it actually answers vs. which one people mistakenly ask it.

7 — Distributed Tracing Backend

How spans that arrive out of order, from different services, get assembled into one trace — and the two competing storage models (indexed search vs. object storage plus a trace-ID lookup) that trade query flexibility for cost.

9 — OTel Collector Pipeline Design

Receivers, processors, and exporters chained into a pipeline; why a platform runs more than one; and the agent/gateway topology that tail sampling specifically forces on that design.

1. Clarify Requirements First

The first-5-minutes clarifying questions for the telemetry ingestion pipeline design — signal types, scale envelope, consistency/durability, multi-tenancy, and protocol — whose answers change the entire architecture.

System Design

Principal/Staff-level system design reference collection for MAANG interview preparation — observability pipelines, distributed systems, reliability engineering, and beyond.