Skip to main content
RunBook Academy

← All break/fix scenarios in Observability

intermediatetempo-ingestion~40 min

Break/Fix: Tempo Receives No Traces

Reported symptoms

  • ●Searching Tempo for the settlement-worker service returns 11 traces against 288 completed runs in the last 24 hours, and has done since the service was redeployed three weeks ago
  • ●It is not zero, which is what makes it confusing: a small and apparently random fraction of runs does appear
  • ●The worker writes trace IDs into its own log lines, and those trace IDs are real - the downstream ledger API spans carrying the same IDs are in Tempo
  • ●The 11 traces that do exist look truncated: the span ID in the run last log line is not in the trace
  • ●Someone already raised, and closed, an unrelated ticket weeks ago: the SDK self-observability scrape job for this service is permanently down
  • ●Someone else already tried the obvious fix - raising the collector queue size and restarting it - and the fraction did not move
  • ●Running the same binary locally as a long-lived process produces complete traces every single time

Evidence

  • · Tempo search filtered by the service name returns 11 results for the last 24 hours; the CronJob has 288 successful completions in the same window
  • · Taking a trace ID from a worker log line and searching it in Tempo returns a trace that contains only ledger API spans and no root span from the worker
  • · Every one of the 11 traces that exist comes from a run that lasted longer than five seconds, and every one is missing the spans from the end of the run
  • · The run duration histogram shows about 96 percent of runs completing in under five seconds
  • · A TCP probe to the collector OTLP port from a pod in the same namespace succeeds, and otelcol_receiver_accepted_spans is healthy and rising from every other service
  • · otelcol_exporter_send_failed_spans is flat at zero and the collector has logged no export errors
  • · The service change three weeks ago reads: replace the opentelemetry-instrument launcher with explicit SDK setup. The diff adds a TracerProvider and adds nothing to the exit path
  • · The collector queue-size change made two days ago is still in place and changed nothing
Diagnosis and resolutionclick to reveal

Root cause

The worker emits its spans into the batch span processor and exits before that processor ever exports them. The specification defaults for a batch span processor hold spans in memory for a scheduled delay of five seconds before an export, and a process that terminates without flushing discards whatever is still in the queue. About 96 percent of this job runs finish in under five seconds, so 96 percent of its spans are created, recorded, buffered and then thrown away inside the application process. They never reach the network, which is why every downstream diagnostic is clean: the collector is healthy because nothing was ever sent to it, the exporter has no failures because it had nothing to export, and the queue size was never the constraint. The runs that do appear are the ones that lasted long enough for at least one scheduled export to fire, which is also why those traces are truncated in a consistent way - the early spans made an export deadline and the closing spans did not. Nothing here is broken in the sense of erroring. The change three weeks ago replaced an auto-instrumentation launcher with a hand-written SDK setup, and the launcher had been installing the shutdown handling that flushed the buffer at exit. That responsibility was not in the diff, was not in the description, and is not visible in any metric the platform collects, because the evidence of it dies with the process. The trace IDs in the logs and the downstream spans are real for the same reason: the SDK ran, generated context and propagated it correctly across the boundary. Link A and link B of the chain were fine all along. The span data simply never left.

Remediation

Flush before exit. Shut the tracer provider down on the way out of the process, in a deferred or registered handler so that it also runs on the error path, because a batch job that fails is the run whose trace you most want. That single change moves the job from 4 percent coverage to complete coverage, and it costs something you must decide about deliberately rather than discover later: shutdown blocks while the final export completes, up to the export timeout, which the specification defaults to thirty seconds. On a CronJob with a deadline, or in a container whose termination grace period is shorter than that timeout, the fix converts missing traces into a job that is killed during its flush, which is the same bug wearing a different symptom. Set the export timeout to a value the job can afford and make sure the grace period exceeds it. Three shortcuts are worth naming and refusing. Switching to a simple span processor exports every span synchronously as it ends and does close the gap, but it puts a network call in the worker path and turns the collector into a latency dependency of the job; it is defensible for a tiny batch process and a poor default to copy across a fleet. Lowering the scheduled delay to a few hundred milliseconds narrows the window without closing it, and buys that with a large increase in export volume. Raising the collector queue does nothing at all, which the estate has already proved by doing it. None of this is a customer-facing outage, so a hold is a legitimate answer: name an owner and a date, and record loudly that incident response for this job currently has no traces, so the next responder does not spend their first twenty minutes discovering it.

Verification

Verify the end of a trace, not the existence of one. The failure mode here produced traces that appeared in Tempo and were quietly incomplete, so a check that asks whether a trace exists would have passed on 11 occasions while the job was 96 percent unobserved. Take the span ID from the run final log line and require it to be present in the trace Tempo returns. Then prove the check can fail by running the pre-fix build once and confirming that same span is absent; you have a ready-made negative control and no reason to trust a green result without it. Once a single run is correct, verify the population rather than the sample: over 24 hours the number of traces carrying the service name must equal the number of completed runs, and any gap is the remaining fraction. Confirm the job runtime has not grown past its deadline and that no run was terminated during its flush, since that is the failure the fix can introduce. Confirm otelcol_receiver_accepted_spans rose by roughly the volume you now expect, which is the independent statement that spans are actually crossing the network rather than merely being created. And revisit the two tickets this incident inherited: the SDK self-observability scrape job is still down and will stay down, and the collector queue change should be reverted rather than left in place as an unexplained setting that the next engineer will assume was load-bearing.

Prevention

Treat a short-lived process as a different instrumentation problem from a server. Every default in a tracing SDK is tuned for a process that outlives its own buffers; a job that runs for two seconds violates that assumption before it starts, and the failure is silent by construction because the evidence dies with the process. Make flush-on-exit a reviewed requirement for any workload that can terminate, and put it on the error path as well as the success path. Review a change that replaces an auto-instrumentation launcher with manual setup as a behaviour change rather than a refactor: the launcher was providing lifecycle handling that nobody had enumerated, and the diff that removes it shows only what was added. Alert on the ratio of completed runs to traces rather than on the presence of traces, because presence is exactly what this failure preserves. Accept that SDK self-observability cannot be scraped from a process that does not live long enough to be scraped, and either push those metrics or acknowledge that link A of the chain is unverifiable for this workload and verify it another way - a permanently-down scrape job that everyone has agreed to ignore is a detector that has been switched off. Finally, test instrumentation with a run whose duration is representative. The local debug run that produced perfect traces every time was not a weaker test of the same program; it was a correct test of a different one.

Reported symptoms

A settlement run fails at 03:40. The on-call engineer has the run’s log, which contains a trace ID, and opens Tempo. Tempo has no trace from settlement-worker.

That is the report. The detail underneath it is stranger:

  • It is not zero. Searching the last 24 hours by service name returns 11 traces. The CronJob completed 288 times in that window. So the service is instrumented, and something works about four percent of the time.
  • The trace IDs are real. Paste one into Tempo and a trace comes back - containing only the downstream ledger API spans. The worker generated a trace ID, propagated it correctly across the service boundary, and is itself absent from its own trace.
  • The traces that exist are cut short. All 11 stop before the end of the run. The span ID in each run’s last log line is not in the trace Tempo returns.
  • Two tickets already exist and were closed as unrelated. The SDK self-observability scrape job for this service has been down for weeks, and somebody raised the collector queue size two days ago on the theory that spans were being dropped under load. The fraction did not move.
  • It works locally. The same binary, run by hand as a long-lived process against the same collector, produces complete traces every time.

Four people have looked at this. The hypotheses on the board are: the collector is dropping spans, Tempo is rejecting them, the sampler is discarding them, and the service is only partly instrumented. Each of those explains some of the evidence and none of them explains all of it.

Evidence provided

Read-only / Safe11 traces for a job that completed 288 times
$ curl -s -G 'http://tempo.obs.example.com:3200/api/search' --data-urlencode 'q={resource.service.name="settlement-worker"}' --data-urlencode 'start=1755000000' --data-urlencode 'end=1755086400' --data-urlencode 'limit=500' | jq '.traces | length'
11

Illustrative output

Read-only / Safethe denominator
$ kubectl get jobs -n batch -l app=settlement-worker -o json | jq '[.items[] | select(.status.succeeded == 1)] | length'
288

Illustrative output

Take a trace ID from the worker’s own log line and ask Tempo what is in it:

Read-only / Safethe downstream service is there; the worker that started the trace is not
$ curl -s 'http://tempo.obs.example.com:3200/api/traces/4d2b18c7f0a95e63' | jq -r '.. | .spans? // empty | .[] | .name' | sort | uniq -c
      3 GET /v1/accounts
    1 POST /v1/postings

Illustrative output

The eleven that did arrive, against the run durations:

# How long do these runs actually take? Compare against the five-second mark.
kubectl get jobs -n batch -l app=settlement-worker \
  -o jsonpath='{range .items[*]}{.status.startTime}{"\t"}{.status.completionTime}{"\n"}{end}' \
  | head -20

Ninety-six percent of runs complete in under five seconds. All 11 traces that reached Tempo came from runs that did not.

The pipeline downstream of the application is unremarkable:

Read-only / Safeaccepted equals sent, failures are zero
$ curl -s http://otel-collector.obs.example.com:8888/metrics | grep -E '^otelcol_(receiver_accepted_spans|exporter_sent_spans|exporter_send_failed_spans)'
otelcol_receiver_accepted_spans{receiver="otlp",transport="grpc"} 4.187442e+07
otelcol_exporter_sent_spans{exporter="otlp/tempo"} 4.187441e+07
otelcol_exporter_send_failed_spans{exporter="otlp/tempo"} 0

Illustrative output

And the change that started it:

Read-only / Safea refactor, described as a refactor
$ git -C /srv/settlement-worker log --oneline --since=4.weeks -- .
e73c0d9 settlement-worker: replace opentelemetry-instrument launcher with explicit SDK setup
b118af4 settlement-worker: bump base image

Illustrative output

Work the evidence before reading on

The five links between an application and a queryable trace are: the SDK running, propagation intact, the collector reachable, the exporter healthy, and the sampler keeping the trace. Four of them can be cleared from the evidence above.

  1. The worker logged a trace ID and the downstream service’s spans carry it. Which two links does that clear, and how completely?
  2. The collector’s accepted count equals its sent count and its failure count is zero. Which link does that clear - and be precise about what “accepted” means and where the counter lives.
  3. Eleven traces exist, all from long runs, all missing their endings. A sampler that dropped 96 percent of traces would produce a different distribution. What kind of mechanism keeps the beginning of a run and discards the end of it?
  4. The same binary works perfectly on a workstation. What is different about that execution, and is it something about the machine or something about the run?

Before continuing: the collector queue was raised two days ago and nothing changed. Why was that a reasonable hypothesis, and what one piece of evidence should have ruled it out before anybody touched the collector?

Root cause

1. The spans never left the process

A tracing SDK does not send a span when the span ends. It hands the span to a span processor, and the production default is a batch processor: spans go into an in-memory queue and are exported on a schedule. The specification’s defaults are a scheduled delay of five seconds, a maximum queue of 2,048 spans, a maximum export batch of 512, and an export timeout of thirty seconds.

A process that exits discards that queue. There is no flush unless something asks for one.

settlement-worker runs for two to four seconds in 96 percent of its executions. Its spans are created, recorded, and buffered, and then the process returns from main and the buffer goes with it. Nothing is dropped, refused, rejected or sampled away, because nothing was ever sent. That is why every downstream diagnostic is clean, and why the collector queue change made no difference: the constraint was never on the network.

2. That is exactly the distribution you would see

The four percent is not random, and it is the most useful number in the incident.

A run that lasts longer than the scheduled delay gets at least one export before it exits. Those runs appear. Runs shorter than the delay never do. The histogram of run durations and the fraction of traces that arrive are the same distribution seen twice.

It also explains the truncation. The runs that do appear were exported at their five-second mark, which captured the spans that had already ended. Every span that ended after that export - including the run’s last span, the one whose ID is in the last log line - was still in the queue when the process exited. The traces are not “incomplete because Tempo lost something”. They are complete records of everything that happened before the only export that ever fired.

Any hypothesis about dropping, sampling or rejection has to explain why the loss is aligned to the end of a run rather than distributed across it. None of them do.

3. The launcher had been doing this all along

The change three weeks ago replaced opentelemetry-instrument with a hand-written SDK setup. The diff adds a tracer provider, a resource, and an OTLP exporter. Every line in it is correct.

What the diff does not contain is the thing that was removed, because it was never written down in this repository: the launcher installed the SDK and its lifecycle handling, including flushing at exit. Moving to explicit setup moved that responsibility to the application, and nobody knew there was a responsibility to move.

This is why the commit message says “refactor” honestly. From inside the change it is one. The behaviour that disappeared was never in the file being edited.

4. The local run tests a different program

The binary that works perfectly on a workstation is started by hand, watched, and stopped with Ctrl-C after several minutes. That execution crosses the scheduled delay dozens of times, so every span is exported long before the process ends, and the missing flush is unreachable.

It is not a weaker test of the same program. It is a correct test of a different one, and it is the reason four people spent three weeks looking downstream of an application that was working.

Resolution

  1. Confirm the diagnosis cheaply before changing anything. Run the job once with an artificially extended duration - a sleep at the end, longer than the scheduled delay - and check whether the complete trace appears. If it does, the spans are being discarded at exit and every downstream hypothesis is closed.
  2. Add the flush. Shut the tracer provider down on the way out of the process, and register it so that it runs on the error path as well as the success path. A settlement run that fails is the run whose trace is worth the most.
  3. Choose the export timeout deliberately rather than inheriting thirty seconds. Pick a value the job can afford to wait, and write down why that number.
  4. Make the container termination grace period larger than the export timeout, and check the job deadline against both. This is the step that stops the fix from producing killed runs during flush.
  5. Do not reach for a simple span processor as the default. It exports each span synchronously and does close the gap, but it makes the collector a latency dependency of the job. Defensible here, wrong as a pattern to copy.
  6. Do not shorten the scheduled delay to a few hundred milliseconds and call it fixed. It narrows the window without closing it, and it multiplies export volume across every service that copies the setting.
  7. Revert the collector queue-size change. It was a reasonable hypothesis, it was tested, and it was wrong. Leaving it in place bequeaths an unexplained setting that the next engineer will assume is load-bearing.
  8. Decide whether this is worth an out-of-hours fix at all. Nothing is customer-facing and no data is being lost outside telemetry. A hold with an owner and a date is a legitimate answer - provided you record loudly that this job currently has no traces, so the next responder does not spend twenty minutes rediscovering it.

Verification

  1. Verify the end of a trace, not the existence of one. Take the span ID from the run final log line and require it to be present in the trace Tempo returns. A check that only asks whether a trace exists would have passed 11 times while the job was 96 percent unobserved.
  2. Prove the check can fail. Run the pre-fix build once and confirm that same final span is absent. You have a ready-made negative control and no reason to trust a green result without one.
  3. Verify the population, not the sample. Over 24 hours, the number of traces carrying the service name must equal the number of completed runs. Any gap is the fraction still being discarded, and it will point at a code path that exits without the flush.
  4. Confirm the job runtime has not grown past its deadline and that no run was terminated during its flush. This is the failure the fix itself can introduce, and it looks like an unrelated flake.
  5. Confirm otelcol_receiver_accepted_spans rose by roughly the volume you now expect. That counter is the independent statement that spans crossed the network rather than merely being created.
  6. Check the error path specifically. Force a failing run and require its trace to be complete. The success path is the easy half and it is not the half that matters at 03:40.
  7. Close the two inherited tickets honestly. The self-observability scrape job will stay down because the process is too short-lived to scrape, so either push those metrics or record that link A is unverifiable for this workload by design.

Prevention

  • Treat a short-lived process as a different instrumentation problem from a server. Every default in a tracing SDK assumes a process that outlives its own buffers. A job that runs for two seconds breaks that assumption before it starts, and it breaks it silently, because the evidence dies with the process.
  • Make flush-on-exit a reviewed requirement for any workload that can terminate, on the error path as well as the success path, and pair it with timeouts that the workload can actually afford.
  • Review a change that replaces an auto-instrumentation launcher with manual setup as a behaviour change, not a refactor. The launcher was providing lifecycle handling nobody had enumerated, and a diff shows what was added, never what stopped happening.
  • Alert on the ratio of completed runs to traces rather than on the presence of traces. Presence is exactly what this failure preserves, which is why it survived three weeks and two closed tickets.
  • Do not let a permanently-down detector become furniture. The dead self-observability scrape job was a true statement about a real gap, and the team had agreed to ignore it long before the gap mattered.
  • Test instrumentation with a run whose duration is representative of production. A long-lived debug session exercises a different program, and every hour spent trusting it is an hour spent downstream of a healthy pipeline.