Skip to main content
RunBook Academy

← All break/fix scenarios in Observability

advancedtrace-sampling~30 min

Break/Fix: Sampling Removes Important Traces

Reported symptoms

  • ●A TraceQL search for error traces on checkout-api over the 20-minute incident window returns 3 traces, none of them the customer transaction under investigation
  • ●Error traces for edge-gateway are reliably present for the same window, which made this look like a checkout-api instrumentation problem
  • ●The gateway collector reports the keep-errors tail-sampling policy keeping around 40 traces an hour, steadily, which the team reads as proof that tail sampling is working
  • ●The platform smoke test injects a synthetic trace every five minutes and it appears in Tempo within a minute, every time, which is treated as end-to-end proof of the pipeline
  • ●Tempo ingest volume and object-storage growth did not change at all when tail sampling was enabled six weeks ago; this was filed as "the tail sampler is efficient" and closed
  • ●Every trace that does arrive is complete - full span tree, every service present, no gaps - so nobody suspects anything is being discarded
  • ●The low-traffic /v2/refunds route has produced no error trace at all in six weeks, despite 147 error log lines carrying trace identifiers in the incident window alone
  • ●decision_wait was raised from 5s to 30s two weeks ago while chasing this, with no observable effect

Evidence

  • · TraceQL count of traces matching status = error for checkout-api in the incident window: 3
  • · Loki count of ERROR lines carrying a trace_id for checkout-api over the identical window: 147
  • · otelcol_processor_tail_sampling_count_traces_kept{policy="keep-errors"} is climbing steadily and otelcol_processor_tail_sampling_traces_dropped_too_early is 0
  • · The gateway pipeline contains no probabilistic_sampler processor, so nothing in the collector fleet is head sampling
  • · systemctl show on a checkout host reports OTEL_TRACES_SAMPLER=parentbased_traceidratio and OTEL_TRACES_SAMPLER_ARG=0.02
  • · The same command on an edge-gateway host reports OTEL_TRACES_SAMPLER=always_on
  • · otelcol_receiver_accepted_spans at the gateway is flat across the tail-sampling cutover date, with no step change in either direction
  • · The smoke-test harness sets OTEL_TRACES_SAMPLER=always_on for its own synthetic requests
Diagnosis and resolutionclick to reveal

Root cause

The tail sampler is working correctly and has never been the problem. The application SDK on checkout-api is still head sampling at two percent, from an OTEL_TRACES_SAMPLER_ARG value that predates the tail-sampling project by two years and was inherited from a base-image default nobody re-read. A head sampling decision is not a filter applied to data that exists; it is a decision not to record, so the ninety-eight percent of traces the SDK drops produce no spans, are never exported, and never reach the gateway. The tail sampler cannot rescue what it is never sent, and its policy counters describe only the traffic it received. Forty kept error traces an hour is roughly two percent of the errors the service actually served, and it looks entirely healthy because a keep-rate has no denominator in it. The rarity effect then makes the fault look endpoint-specific: the two busiest routes emit enough errors that two percent still yields a few, while /v2/refunds served 147 errors in the incident window, for an expected yield of about three retained traces and an observed yield of none, which is exactly what a two percent sample of a small number looks like. Because the sampler is parent-based, the decision propagates to every downstream service on the same trace identifier, so the pipeline produces whole traces or nothing at all and never the partial traces that would have made a sampler the obvious suspect. Every check the team ran was insensitive to this by construction: the smoke-test canary sets always_on and therefore never traverses the production sampler, the flat ingest volume across the cutover was the strongest available evidence that the producers had not changed and was read as a compliment, and decision_wait tunes a failure mode that was not occurring.

Remediation

Start by saying plainly that the tail sampler is healthy and that repairing it is not the work, because six weeks of effort have already gone into the wrong component. Then resist the single-line fix. Setting the SDK to always_on multiplies gateway ingress by roughly fifty times in one restart, against a gateway whose num_traces and memory were sized for two percent of the traffic and whose decision_wait was raised to 30 seconds a fortnight ago, which multiplies the buffer requirement again. The realistic outcome of that change is an out-of-memory kill on the one component that holds every in-flight trace, which drops everything including the errors the change was made to keep, so you would end the day with less than you started. Do the arithmetic before the change instead: num_traces needs to be about the expected new traces per second multiplied by decision_wait with headroom for variance, and the memory that implies is that count multiplied by the average trace size. Bring decision_wait back toward the p99 trace duration first, since it was raised chasing a cause that was not there and is now purely a cost. Then raise the sampling ratio in stages on the one service under investigation rather than fleet-wide, holding at each step long enough to read gateway memory, refused spans, dropped-too-early traces and exporter queue depth before taking the next one. Holding at an intermediate ratio is a legitimate landing place with a named owner and a review date, not a failure to finish. In parallel, move the sampler setting out of a base-image default into declared per-service configuration, and correct the smoke test so it traverses the same sampler path as real traffic. Be explicit that the 147 errors from the incident window are gone permanently: a trace that was never recorded cannot be recovered from anywhere, and the investigation has to proceed on logs.

Verification

Verify the producer, not the gateway, because every gateway signal was already green while the platform was losing ninety-eight percent of its traces. Read the sampler configuration back from a running host and confirm the ratio is what was intended rather than what the base image supplied. Then check the quantity that was never measured: the count of traces matching status = error in Tempo must now agree, within a few per cent, with the count of error log lines carrying a trace identifier over the same window - the same independent comparison that exposed the fault, run again as the proof of the fix. Confirm otelcol_receiver_accepted_spans at the gateway has stepped up by roughly the factor the ratio change implies; if ingest did not move, the change did not take effect and nothing else you observe means anything. Confirm the gateway is not now failing quietly under the new load: traces_dropped_too_early must remain zero and refused spans must remain zero through a traffic peak, not only at the moment of the change. Test the rare case directly rather than inferring it: emit twenty errors on the low-traffic route through the ordinary request path, and require all twenty to be queryable in Tempo. Nineteen is a failure, because the whole point is the one you did not get. Confirm the baseline probabilistic policy is still discarding routine traffic, since a tail sampler that now keeps everything is a different incident arriving in a fortnight. Finally, prove the new detector can fail: set the sampler back to 0.02 in staging and confirm the head-sampling alert fires.

Prevention

Publish one ratio and alert on it: spans produced by the service against spans accepted at the gateway. If any head sampler sits anywhere in the path that number is not one, and it is the only signal that can see a decision made before the pipeline begins, because a dropped trace emits nothing at all. Never read a keep-rate counter without its denominator - forty errors an hour kept is a meaningless number until it is set beside the errors the service actually served, and the log-derived count is an independent source that is already being collected. Make sampler configuration declared, versioned, per-service configuration rather than a base-image default, so that "we use tail sampling" becomes a statement someone can check instead of a statement about intent. Require every synthetic canary to traverse the same sampler path as production traffic; a canary that sets always_on validates the transport and asserts nothing about the sampling decision, which is the part that was broken. Treat any change to decision_wait as a change to the memory budget and make the buffer arithmetic part of the same review. When a sampling change is deployed, the acceptance test is a measured step in gateway ingest: if the volume does not move, the change did not reach the producers, and a flat graph after a cutover is a finding rather than a compliment. And run a standing rare-error canary on a low-volume route with an alert when its trace is not queryable within a stated interval, because the failure this class produces is the absence of something nobody is looking for.

Reported symptoms

A customer was charged twice on a refund. The support ticket has a request identifier, a timestamp, and a twenty-minute window. The on-call engineer opens Tempo, searches { resource.service.name = "checkout-api" && status = error } over the window, and gets three traces. None of them is the one.

This is the fourth time in six weeks. The team enabled tail sampling at the gateway specifically so that this would stop happening, and the change is live.

Four other things are on the ticket, each of which was taken as reassurance.

The tail sampler looks healthy. The gateway reports the keep-errors policy keeping about forty traces an hour, steadily, all day. Every time somebody has checked, the counter was climbing. Nothing is being dropped too early.

The canary lands every time. A platform smoke test injects a synthetic trace every five minutes; it is queryable in Tempo within a minute, without exception, and has been for months. That is treated as end-to-end proof that the pipeline works.

Another service is fine. Error traces for edge-gateway are reliably present for the same windows. Two teams, the same gateway, the same Tempo. That made this look like a checkout-api instrumentation problem, and two sprints went into the SDK integration.

The traces that do arrive are perfect. Full span trees, every service present, no holes where a dependency should be. Nothing about them suggests anything is being discarded.

One observation contradicts all of that and was never followed up. When tail sampling was enabled six weeks ago, Tempo’s ingest volume and object-storage growth did not change. Not by a little - at all. The note in the change ticket reads “the tail sampler is efficient”, and the ticket was closed.

Two weeks ago somebody raised decision_wait from 5s to 30s to see if traces were being decided before they were complete. No effect.

Evidence provided

The cheapest useful thing here is not a collector metric. It is counting the same quantity twice, from two systems that do not share a failure.

Read-only / Safe147 errors, each one carrying a trace identifier
$ logcli query --quiet --limit 0 --stats \
'{service="checkout-api"} |= "ERROR" | json | trace_id != ""' \
--from 2026-08-17T13:40:00Z --to 2026-08-17T14:00:00Z \
| wc -l
147

Illustrative output

One hundred and forty-seven errors happened, and the application knew the trace identifier of every one of them. Tempo holds three.

That ratio is about two percent, and two percent is a number that should make anyone reach for a sampler. But the gateway insists it is keeping every error it sees.

Read-only / Safea policy that is working, on the traffic it is given
$ curl -s http://otel-gateway-01.internal.example.com:8888/metrics \
| grep -E 'tail_sampling_(count_traces_kept|traces_dropped_too_early)'
otelcol_processor_tail_sampling_count_traces_kept{policy="keep-errors"} 3841
otelcol_processor_tail_sampling_count_traces_kept{policy="keep-slow"} 902
otelcol_processor_tail_sampling_count_traces_kept{policy="keep-baseline"} 71204
otelcol_processor_tail_sampling_traces_dropped_too_early 0

Illustrative output

Nothing in the collector fleet is head sampling either.

Read-only / Safeno collector-side head sampler anywhere in the pipeline
$ grep -c probabilistic_sampler /etc/otelcol/config.yaml
0

Illustrative output

So the decision is being made before the pipeline starts. The producer is the only place left.

Read-only / Safetwo percent, set at the producer
$ ssh checkout-01.internal.example.com \
'systemctl show checkout-api --property=Environment' \
| tr ' ' '\n' | grep OTEL_TRACES
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.02

Illustrative output

Read-only / Safethe service whose error traces are always there
$ ssh edge-gw-01.internal.example.com \
'systemctl show edge-gateway --property=Environment' \
| tr ' ' '\n' | grep OTEL_TRACES
OTEL_TRACES_SAMPLER=always_on

Illustrative output

Work the evidence before reading on

The tail sampler keeps every error it sees. One hundred and forty-seven errors occurred. Three traces exist. All three statements are true.

  1. A head sampler decides not to record. What does the gateway receive for a trace the SDK declined to record, and what could any downstream policy do about it?
  2. keep-errors is keeping forty traces an hour and that looks healthy. Healthy compared to what? Name the denominator the counter does not contain.
  3. /v2/refunds served 147 errors in twenty minutes and produced no trace; the two busiest routes produce error traces regularly. Both are sampled at the same rate. Why does one look broken and the other does not?
  4. The sampler is parentbased_traceidratio. Given that, predict whether the traces that do arrive should be complete or full of holes, and reconcile your answer with the symptom that made everyone rule out sampling.
  5. Ingest volume did not move when tail sampling was enabled. If the producers had been shipping everything, what should that graph have done - and what does a flat line tell you?

Before continuing: state what would happen to the gateway if you set OTEL_TRACES_SAMPLER=always_on on every service this afternoon.

Root cause

1. A head decision is not a filter, it is an absence

parentbased_traceidratio with an argument of 0.02 hashes the trace identifier at the entry point and, for ninety-eight percent of requests, decides not to sample. That is not a decision to record a trace and discard it later. The spans are not recorded, nothing is exported, and nothing arrives anywhere.

The tail sampler is downstream of that. It cannot apply a policy to data it was never sent, and there is no configuration of the gateway that changes this. Six weeks of tail-sampling work were applied to two percent of the problem, and applied correctly.

2. A keep-rate has no denominator in it

keep-errors at forty an hour was the strongest evidence the team had that the pipeline was doing its job, and it is not evidence of anything. The counter reports how many of the traces that reached the gateway matched the error policy. It has no opinion about how many errors the service served, because it never saw them.

Set beside the log count, forty an hour is about two percent of reality. The same number, unqualified, reads as success. This is the single most reusable idea in the incident: a ratio computed entirely inside the thing you are testing tells you that the thing is internally consistent, not that it is right.

3. Rarity made it look endpoint-specific

RouteErrors in windowExpected kept at 2%Observed
/v2/checkout1,910about 3841
/v2/cart640about 1311
/v2/refunds147about 30

Every row is sampled identically. The busy routes produce enough errors that two percent still yields a visible handful, so the pipeline looks fine when anyone spot-checks it. The low-volume route yields an expected three and an observed zero, which is unremarkable for a sample of that size and looks exactly like a route whose instrumentation is broken.

The routes that matter most in an investigation are usually the rare ones. Sampling degrades them first and degrades them invisibly.

4. Every check the team ran was insensitive to the cause

The canary sets always_on for its own requests, so it never passes through the sampler that production traffic passes through. It proves the exporter, the network, the gateway and Tempo all work. It says nothing about the decision, which is the part that was broken.

The flat ingest volume was the loudest available evidence and was read backwards. If the producers had begun shipping every span for the gateway to decide on, ingest should have jumped by roughly fifty times on the cutover date. A graph that does not move after a change of that magnitude means the change did not reach the producers.

The decision_wait increase addresses traces being decided before they are complete. That was not happening - traces_dropped_too_early is zero - so the change had no effect, and it quietly multiplied the gateway’s buffer requirement by six.

Resolution

  1. Say plainly, in the channel, that the tail sampler is healthy and has been throughout. Six weeks of work went into the wrong component because a green counter was read as a green pipeline, and the next person will repeat it unless the correction is explicit.
  2. Do the buffer arithmetic before changing anything. num_traces needs to be about expected new traces per second multiplied by decision_wait, with headroom for variance, and the memory that implies is roughly that count multiplied by the average trace size. Get both numbers at the target ratio, not at the current one.
  3. Bring decision_wait back toward the measured p99 trace duration. It was raised to 30s chasing a cause that was not there, traces_dropped_too_early proves the original value was adequate, and every second of it is now buffer you have to pay for at fifty times the volume.
  4. Raise the ratio on the one service under investigation rather than fleet-wide, in stages - 0.02, then 0.1, then 0.5, then 1.0 - holding at each step long enough to read gateway memory, refused spans, dropped-too-early traces and exporter queue depth through at least one traffic peak.
  5. Treat stopping short as a legitimate outcome. If the gateway cannot hold full rate today, holding at an intermediate ratio with a named owner and a review date is a better answer than a heroic cutover, and it is a much better answer than an out-of-memory kill.
  6. Add gateway capacity horizontally rather than buying headroom by trimming decision_wait below the p99. One of those choices costs money and the other silently corrupts the decision.
  7. Move the sampler setting out of the base-image default into declared per-service configuration in the same change, so that the fleet-wide claim "we use tail sampling" becomes something a review can check.
  8. Correct the smoke test so it traverses the production sampler path instead of setting always_on for itself. A canary that exempts itself from the mechanism under test is worse than no canary, because it produces a green signal.
  9. Tell the investigating team that the 147 errors from the incident window are gone permanently. A trace that was never recorded does not exist in any buffer, backend or backup, and the investigation has to proceed from logs.

Verification

  1. The producer is configured as intended. Read the sampler settings back from a running host rather than from the deployment manifest; the whole incident is the difference between those two.
  2. The independent counts agree. Traces matching status = error in Tempo must now land within a few per cent of the count of error log lines carrying a trace identifier over the same window. This is the comparison that exposed the fault, and it is the one that confirms the fix.
  3. Ingest moved. otelcol_receiver_accepted_spans at the gateway has stepped up by roughly the factor the ratio change implies. If the graph is flat, the change did not reach the producers and nothing else you measure means anything.
  4. The gateway is not failing quietly under the new load. traces_dropped_too_early stays at zero and refused spans stay at zero through a full traffic peak, not merely in the ten minutes after the change.
  5. The rare case works, tested directly. Emit twenty errors on the low-traffic route through the ordinary request path and require all twenty to be queryable in Tempo. Nineteen is a failure: the entire point is the one you did not get.
  6. The baseline policy still discards routine traffic. Confirm the tail sampler is not now keeping everything, or the storage bill becomes the next incident in a fortnight.
  7. The detector can fail. In staging, set the sampler back to 0.02 and confirm the produced-versus-accepted alert fires. A guard that has only ever been green has never been tested.

Prevention

  • Publish one ratio: spans produced against spans accepted at the gateway. If any head sampler sits anywhere in the path, that number is not one. It is the only signal capable of seeing a decision made before the pipeline begins, because a trace that was never recorded emits nothing at all.
  • Never read a keep-rate without its denominator. Forty errors an hour kept is meaningless until it is set beside the errors the service actually served. The log-derived count is independent, already collected, and costs one query.
  • Make sampler configuration declared and per-service. A base-image default is not configuration; it is an assumption that survives every review because it appears in no diff.
  • Canaries must traverse the mechanism under test. A synthetic trace that sets always_on validates transport and storage and asserts nothing at all about the sampling decision.
  • A change to decision_wait is a change to the memory budget. Put the buffer arithmetic in the same review, every time.
  • After a sampling cutover, the acceptance test is a measured step in gateway ingest. A flat graph after a change of that magnitude is a finding, not a compliment.
  • Run a rare-error canary on a low-volume route and alert when its trace is not queryable within a stated interval. The failure this class produces is the absence of something nobody is looking for, and only a standing test looks for it.