Sampling
Tracing every request is the one part of this stack that genuinely doesn't scale by default. At Harborline's summer-Saturday peak, search alone takes 300 requests per second, each producing 8–12 spans — over 200 million spans a day from one service, almost all of them describing identical healthy cache hits that nobody will ever look up.
Sampling is the deliberate decision to keep some traces and drop the rest, and the entire craft is which ones. This is the second of the book's three costs from Chapter 1, applied to the most expensive signal — and applied badly, it deletes exactly the traces this chapter exists to capture.
The Bill for Completeness
The arithmetic deserves to be explicit: spans per request, times requests per second, times bytes per span, compounded across five services. Storage is actually the smaller line item — export bandwidth off the hosts, Collector CPU at the gateway, and query-time noise all scale with the traces you keep. And 99% of what a full retention buys is redundant: the ten-thousandth healthy 40 ms search trace teaches nothing the first one didn't.
Head Sampling
Head sampling makes the decision at the root span's birth, inside the SDK, before anything about the request is known. ParentBased(TraceIdRatioBased(0.10)) keeps 10% of new traces, and the ParentBased wrapper makes every downstream service honor the sampled bit arriving in the traceparent flags byte (Topic 39) instead of re-rolling the dice. The root decides once; bookings, payments, and notifier all follow. Cheap, stateless — and blind.
The Blindness, Precisely
Head sampling decides before the outcome exists, so at 10% it discards 90% of the errors and 90% of the slow requests right along with the boring traffic — the sampler cannot favor what it cannot yet see. Harborline's Saturday mystery fires a few dozen slow checkouts an hour; a 10% head sample would have captured a handful, and a cost-driven 1% would likely have captured none. This is the observability-native footgun: sampling away exactly the requests you built tracing to see.
Tail Sampling
Tail sampling moves the decision to the end of the trace, into the Collector gateway. The tail_sampling processor — contrib distribution, as Topic 41 flagged — buffers each trace for a decision_wait window of 10–30 seconds, then applies policies top to bottom, now knowing the trace's duration and status. Harborline's policy keeps every trace with an error status, keeps every trace slower than 500 ms — the checkout SLO threshold, reused deliberately — and takes a probabilistic 5% of the healthy rest.
processors:
tail_sampling:
decision_wait: 30s
policies:
- name: keep-errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: keep-slow
type: latency
latency: { threshold_ms: 500 }
- name: keep-baseline
type: probabilistic
probabilistic: { sampling_percentage: 5 }
Read the policy as keep-rules, in words: 100% of failures, 100% of SLO breaches, a thin residue of healthy baseline for comparison. The interesting traces at Harborline are rare by definition — that is what makes them interesting — and this is the mechanism that guarantees the Saturday trace exists in Topic 43 instead of surviving a dice roll.
What Tail Sampling Costs
The trade is memory and topology. The gateway holds every in-flight trace for the full decision window, which is real memory at peak load — and every span of one trace must reach the same Collector instance, or each instance sees a fragment and decides on incomplete evidence. With Harborline's single gateway on obs-01 the affinity is free. A scaled-out gateway tier needs the load-balancing exporter in front, routing spans by trace ID, before tail_sampling can be trusted.
Composing a Policy
Head and tail stack. A head sample in the SDK caps what leaves the hosts at all — search at Saturday peak is the service that needs it — and tail policies at the gateway spend the surviving budget on errors and outliers. One caveat travels with any sampling at all: kept traces can no longer answer "how many." After keep-all-errors sampling, failures are overrepresented by design, so rates and ratios stay the metrics' job, and traces answer where and why.
Head sampling — decides at the trace's start, in the SDK, with zero knowledge of the outcome: no buffering, no topology constraints, and it cuts export cost at the source. Choose it as a cheap first-stage cap on healthy high-volume traffic.
Tail sampling — decides after the trace completes, in the Collector, seeing duration and status: it keeps 100% of errors and slow traces while sampling the boring 99%, at the price of buffering memory and trace-to-instance affinity. Choose it wherever the interesting traces are rare — which is every debugging scenario this book cares about.
- Setting
TraceIdRatioBased(0.01)fleet-wide to control cost — the one-in-ten-thousand error trace now survives with 1% probability, and the incident review finds four spans where the evidence should be. - Using a plain
TraceIdRatioBasedsampler withoutParentBasedin downstream services — each service re-rolls the dice,bookingskeeps a trace thatpaymentsdropped, and the backend fills with half-traces that look like propagation bugs. - Scaling the gateway to two instances behind a round-robin load balancer with
tail_samplingenabled — spans of one trace split across instances, each sees a partial trace, and policy decisions become coin flips; the load-balancing exporter routing by trace ID is the prerequisite. - Setting
decision_waitto 5 s to save gateway memory — Harborline's slowest checkouts run 9+ seconds, so the traces the latency policy exists to keep get evaluated before their spans finish arriving, and the policy keeps fragments. - Reporting error rates from sampled traces — after keep-all-errors tail sampling, errors are overrepresented by design; rates and ratios come from Prometheus counters, traces come sampled.
- Write the tail policy as keep-rules first: 100% of
status_code = ERROR, 100% of latency over the SLO threshold (500 ms for checkout), then a probabilistic residue of 5–10% for healthy-baseline comparison traces. - Use
ParentBasedsamplers in every service so the root's decision propagates and traces arrive whole or not at all. - Size
decision_waitpast the p99.9 of trace duration — a policy that decides before the slow traces finish arriving is a policy that samples them away. - Alert on the sampling pipeline itself via the Collector's own metrics — a misconfigured policy silently dropping everything looks identical to a healthy quiet system until the day you need a trace.
Knowledge Check
Why does a 10% head sample discard 90% of error traces, not just 90% of boring ones?
- The sampler preferentially drops errors to reduce noise
- The decision is made before the outcome exists, so errors get no special treatment
- Error spans fail to export because the request crashed
- ParentBased overrides the sampling ratio specifically for failed and errored requests
Why does tail_sampling require all spans of a trace to reach the same Collector instance?
- OTLP connections are pinned per trace at the protocol level
- Splitting traces would double the gateway's memory use
- The policy evaluates the whole trace, so a partial view produces wrong decisions
- Tempo can only ingest a trace from one source
What does wrapping the sampler in ParentBased guarantee across Harborline's five services?
- One decision at the root, honored by every downstream service
- A higher overall sampling rate than the configured ratio
- That error traces are always kept regardless of the ratio
- That traceparent headers are injected into every outbound call
After deploying keep-all-errors tail sampling, where should Harborline's checkout error rate come from?
- Counting error traces vs healthy traces in Tempo
- The 5% probabilistic residue, scaled up by 20
- The Collector's otelcol_* pipeline metrics
- Prometheus counters, which count every request
Why does Harborline run decision_wait at 30 s instead of a snappier 5 s, and what does that trade?
- Export bandwidth for faster queries
- Gateway memory for complete slow traces — checkouts run 9+ seconds
- A lower sampling rate for a higher one
- Instance affinity for round-robin load balancing across gateway replicas
You got correct