Trace Gap: Test Failures That Outlive Their Spans

Most teams wire up OpenTelemetry, ship traces to Honeycomb or Datadog, and assume the observability problem is solved. Then a test fails at 2 AM, the span is already gone — TTL expired, sampler dropped it, or the exporter flushed before the assertion fired — and the trace gap opens. The failure exists in your JUnit XML. The context that explains it does not.

This is the trace gap: the window between when a test failure is recorded and when the distributed trace that corresponds to it is still queryable. It is especially acute in synthetic and distributed test environments, where a single test case may touch five services, three queues, and a database before it asserts anything. The span tree for that work can be gigabytes across a 30-minute suite run; most backends are not configured to retain it long enough for a human to act on it.

By the end of this article you will know how to correlate test failures to their spans reliably, what sampling and retention settings actually cause the gap, and how to instrument your test runner so the trace is still there when the postmortem starts.

Build Better Test Data for Modern Systems

Learn practical strategies for generating, managing, validating, and scaling reliable test data.

Learn more

What a Trace Gap Is and Why It Happens in Test Infrastructure

A trace gap is not a bug in your tracing backend — it is a configuration mismatch between the lifecycle of a test failure artifact (JUnit XML, Allure report, ReportPortal entry) and the retention policy of the corresponding trace data. Test artifacts live in object storage or a database for days or weeks. Traces, by default, live for hours in most backends: Honeycomb's free tier retains 8 days, Datadog's default APM retention is 15 days, but tail-based samplers drop the majority of traces before they are even written. A flaky test that reproduces once every 72 hours is almost guaranteed to lose its trace.

The problem compounds in distributed test scenarios — Argo Workflows running parallel Playwright shards, k6 load tests hitting a microservice mesh, or Selenium Grid sessions that fan out across containers. Each worker emits spans with its own clock skew and service name. Without a stable test.run_id propagated as a baggage attribute through every span, there is no join key. The failure lands in your test results store; the trace is orphaned in a different retention bucket, attributed to a service name that does not match any test identifier your dashboard knows about.

Closing the Gap: Instrumentation, Retention, and the Join Key

The fix starts at the test runner. Every test execution needs a stable, propagated trace context that survives from the first HTTP call to the final assertion. In Pytest, inject a test_run_id as an OTEL baggage value at session start and attach it to every span your fixtures emit:

# conftest.py — OpenTelemetry context propagation for Pytest
import uuid
from opentelemetry import baggage, context
from opentelemetry.baggage.propagation import W3CBaggagePropagator

TEST_RUN_ID = str(uuid.uuid4())

@pytest.fixture(autouse=True, scope="session")
def inject_trace_context():
    ctx = baggage.set_baggage("test.run_id", TEST_RUN_ID)
    token = context.attach(ctx)
    yield
    context.detach(token)

That test.run_id propagates into every downstream service call via W3C Baggage headers, so every span in the trace — whether emitted by your FastAPI service or a Kafka consumer — carries the same identifier. Now your ClickHouse or BigQuery test results table and your tracing backend share a join key.

-- ClickHouse: join test failures to their trace retention status
SELECT
    tf.test_name,
    tf.failure_message,
    tf.run_id,
    t.trace_id,
    t.root_span_start_time,
    dateDiff('hour', t.root_span_start_time, now()) AS hours_since_span
FROM test_failures tf
LEFT JOIN traces t ON tf.run_id = t.baggage_test_run_id
WHERE tf.created_at >= now() - INTERVAL 7 DAY
  AND t.trace_id IS NULL   -- gap: failure exists, span does not
ORDER BY tf.created_at DESC;

That query surfaces every failure in the last 7 days that has no matching trace — your gap inventory. Once you know the volume, tune your sampler. For test traffic, force-sample all traces where test.run_id is present rather than relying on probabilistic sampling. In the OpenTelemetry Collector, a tailsampling processor rule covers this:

# otel-collector-config.yaml — force-keep all test-originated traces
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: keep-test-traces
        type: string_attribute
        string_attribute:
          key: baggage.test.run_id
          values: [".*"]
          enabled_regex_matching: true
      - name: probabilistic-rest
        type: probabilistic
        probabilistic:
          sampling_percentage: 5

Retention is the second lever. In Honeycomb, create a derived column on baggage.test.run_id and use it in a Board query pinned to your CI environment. In Datadog, add a retention filter: env:ci @baggage.test_run_id:* — this keeps 100% of test traces for 15 days without inflating your APM bill for production traffic. One team running 4,000 Playwright tests per day found that connecting distributed traces to test failures this way dropped triage time from 22 minutes per failure to under 4 — the span was simply always there when the on-call engineer opened the report.

For synthetic test data scenarios — tests that generate their own fixture data and then assert on side effects in downstream systems — attach the synthetic data seed ID as a second baggage attribute (test.seed_id). This lets you correlate not just the trace but the exact database state at the time of the failure, which is invaluable when a test fails intermittently due to race conditions in data setup rather than application logic.

Where Senior Engineers Still Get Burned by Trace Gaps

The most common mistake is treating the test runner as outside the trace boundary. Engineers instrument the application under test thoroughly but never start a root span in the test itself. The result: spans exist for every service call, but they are all root spans with no common parent. There is no single trace to query — only a scatter of orphaned spans that share a timestamp window but nothing else. The fix is a single parent span per test case, started before the first fixture and ended after the last assertion, with test.name and test.suite as span attributes.

The second failure mode is clock skew in distributed test environments. Argo Workflows pods, Selenium Grid nodes, and k6 injectors often run on different hosts with NTP drift of 50–200 ms. That is enough to break trace assembly in backends that use strict time-window joins. Use trace_id propagation — not timestamp correlation — as your primary join strategy. If you are still connecting test failures to production logs by timestamp alone, you will lose data at exactly the moments that matter most: high-load, high-latency failures where clock drift is worst.

Myths About Trace Coverage in Test Environments

Myth 1: If the test passes, the trace doesn't matter. Passing tests with high-latency spans are early warnings of degradation. A test that passes in 800 ms today but whose P95 span duration has been climbing 5% per week will fail in production before it fails in CI. Trace data on passing tests is where continuous quality feedback loops get their signal — not just the red builds. Ignoring passing-test traces means your quality system is reactive by design.

Myth 2: Synthetic test data is clean data — no tracing needed. Synthetic data generation is itself a distributed operation in most modern stacks: a factory calls an API, which writes to a queue, which triggers a consumer. Each of those hops is a span. When a test fails because the synthetic data was not ready — a timing issue in setup, not a bug in the application — you need the trace of the data generation phase, not just the assertion phase. Teams that skip tracing on setup fixtures spend hours debugging what a single Gantt-style trace view would show in 30 seconds: the consumer lagged, the data arrived late, the test was never going to pass.

Trace gaps are a retention and instrumentation problem, not a fundamental limit of distributed tracing. Propagate test.run_id as W3C Baggage, force-sample all CI-origin traces at the collector, and build the ClickHouse join query to surface your gap inventory weekly. Once the join key is stable, the next step is wiring that trace data into automated triage — the patterns around what AI-assisted tooling can and cannot do with span data are worth reading before you build that layer.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles