Logs tell you what happened; traces tell you what happened inside what. When an integration test fails because a request took the wrong branch, a trace shows the whole path: the handler span, the database query spans under it, the outbound HTTP call to the pricing service that returned an error, and the retry that followed. Each span carries timing, attributes and status, and the parent-child structure makes the order of events and their nesting explicit in a way interleaved log lines never do.
OpenTelemetry makes that tree available inside a test with very little setup. An in-memory exporter collects finished spans instead of sending them anywhere; the test can then assert that required attributes are present, or — more often — print the tree when the test fails so the next person to debug it can see where the request went. The main pitfalls are global state (the tracer provider can be set only once per process) and asynchronous export, both of which have simple fixes.
Prerequisites
opentelemetry-sdk >= 1.25,opentelemetry-instrumentation-httpxand-sqlalchemy(optional),pytest >= 8.0.- Background from Logging and observability for debugging.
Solution
# conftest.py
import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
_EXPORTER = InMemorySpanExporter()
def pytest_configure(config):
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(_EXPORTER)) # synchronous export
trace.set_tracer_provider(provider) # once per process
@pytest.fixture
def spans():
_EXPORTER.clear()
yield _EXPORTER
_EXPORTER.clear()
def render_tree(finished):
by_parent = {}
for s in finished:
by_parent.setdefault(s.parent.span_id if s.parent else None, []).append(s)
lines = []
def walk(parent, depth):
for s in sorted(by_parent.get(parent, []), key=lambda s: s.start_time):
ms = (s.end_time - s.start_time) / 1e6
lines.append(f"{' ' * depth}{s.name} {ms:.1f} ms {s.status.status_code.name}")
walk(s.context.span_id, depth + 1)
walk(None, 0)
return "\n".join(lines)
# test_checkout_trace.py
from opentelemetry.trace import StatusCode
def test_checkout_marks_pricing_failure(client, spans, pricing_down):
resp = client.post("/checkout", json={"cart": "c1"})
assert resp.status_code == 503, render_tree(spans.get_finished_spans())
finished = {s.name: s for s in spans.get_finished_spans()}
call = finished["POST pricing"]
assert call.status.status_code is StatusCode.ERROR
assert call.attributes["http.response.status_code"] == 500
assert finished["checkout"].attributes["cart.id"] == "c1"
AssertionError:
checkout 48.2 ms ERROR
SELECT carts 1.9 ms UNSET
POST pricing 20.4 ms ERROR
POST pricing 22.1 ms ERROR
Why this works
The SDK's TracerProvider sends every finished span to its processors. SimpleSpanProcessor exports each span synchronously as it ends, so by the time the request returns, every span it created is already in the in-memory exporter. The production default, BatchSpanProcessor, exports on a background thread in batches, which is efficient but means a test can finish before its spans arrive; in tests, synchronous export removes that race.
The provider is set in pytest_configure because OpenTelemetry's global provider can be set only once — later calls log a warning and are ignored. Setting it at session start guarantees every tracer created by application code or instrumentation libraries during the run uses the test provider. The function-scoped fixture then only needs to clear the exporter, so each test sees just its own spans.
Instrumentation libraries do the rest. HTTPXClientInstrumentor().instrument() wraps outgoing requests in client spans with standard attributes; SQLAlchemyInstrumentor().instrument(engine=engine) does the same for queries. Because they use the global provider, their spans land in the same exporter and nest under whatever span was current when the call was made.
Traces as a debugging attachment
Most tests should not assert on spans at all. Span names and attributes change as instrumentation libraries evolve, and asserting on them couples tests to observability details. The more durable use is as a debugging aid: when a test fails, print the tree.
A hook in conftest.py can do that automatically for every test that uses the spans fixture:
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
if rep.when == "call" and rep.failed and "spans" in item.fixturenames:
rep.sections.append(("trace", render_tree(_EXPORTER.get_finished_spans())))
The failure report then contains a "trace" section next to captured logs and stdout. For flaky integration tests in particular, where the failure is rare and the logs are noisy, a compact tree of what the request did — which calls, in which order, how long each took, which failed — is often the fastest route to the cause.
Assert on spans only where tracing itself is a requirement: a dashboard depends on cart.id being set, an alert depends on errors being marked with StatusCode.ERROR, a service-level objective depends on a span existing for every checkout. Those assertions protect real consumers of the telemetry and are worth their maintenance cost.
Following a request across services in an integration test
The in-memory exporter captures spans from one process. Integration tests often start the service under test in a subprocess or container, and the interesting spans are produced there. Two approaches keep the whole path visible.
The first is to run a collector for the test session. Start an OpenTelemetry Collector container (or the lightweight Jaeger all-in-one image) in a session fixture, point the service's OTEL_EXPORTER_OTLP_ENDPOINT at it, and query the collector's API at the end of a failing test for all spans with the test's trace id. The test process starts the trace — its HTTP client instrumentation injects a traceparent header into the request — and the service continues it, so everything shares one trace id and can be retrieved as a single tree.
The second is lighter: have the service write spans to a file with the console exporter, one JSON object per line, into a directory the test can read. After a failure, the test loads the file, filters by trace id and renders the same tree. There is no extra container, and the file doubles as a CI artefact.
In both cases, the key detail is propagation. Without the traceparent header, the service starts a new trace for every request and the test cannot find its spans. The HTTP client instrumentation adds the header automatically; hand-built requests with a bare socket or a mocked transport need opentelemetry.propagate.inject(headers) called explicitly.
Using span timings to explain slow tests
Spans also answer a question that is awkward with other tools: why is this particular test slow? A profiler shows where CPU time goes, but integration tests are usually slow because they wait — on the database, on HTTP calls, on retries with backoff. Span durations measure exactly that waiting, per operation.
Rendering the tree for a slow test often makes the problem obvious. A test that takes three seconds might show a single fast request followed by two pricing calls of a second each, revealing that the fake pricing service was configured with a realistic timeout the test never needed. Another might show forty sequential SELECT spans where one query with a join would do — an N+1 query pattern that is equally slow in production. Neither would stand out in a --durations report, which only gives the test's total time.
A small addition to the report hook — appending the tree when a test exceeds a duration threshold, not only when it fails — turns this into a routine check. Slow tests then carry their own explanation in the CI output.
Edge cases and failure modes
- Provider already set. If application import code calls
set_tracer_providerbeforepytest_configure, the test provider is ignored. Make application setup skip provider creation when one exists, or gate it behind an environment variable. - Spans from other tests. Background threads that finish after their test leak spans into the next one. Join threads in teardown, or filter by trace id.
- Async context propagation. Spans started in
asynciotasks nest correctly only if context propagates; tasks created withcreate_taskcopy context automatically, but thread pools do not — usecontextvars.copy_context().run. - xdist. Each worker process has its own provider and exporter; nothing needs sharing, but do not expect spans from one worker in another.
- Sampling. A sampler configured from the environment may drop spans. Use
ALWAYS_ONin the test provider.
Frequently Asked Questions
How do I capture OpenTelemetry spans in a pytest test?
Configure a TracerProvider with a SimpleSpanProcessor and an InMemorySpanExporter in a fixture, run the code under test, then read exporter.get_finished_spans(). Clear the exporter between tests.
Why are no spans exported in my test?
Usually because the global tracer provider was set before the test fixture ran, since OpenTelemetry allows setting it only once, or because a BatchSpanProcessor has not flushed. Use SimpleSpanProcessor in tests and set the provider once per session.
Should tests assert on spans? Only where tracing is part of the contract, such as required attributes for dashboards or error status on failures. Otherwise use spans as a debugging aid attached to failing tests rather than as assertions.
Related
- Logging and Observability for Debugging — logs, metrics and traces for debugging.
- Structured Logging That Survives pytest Capture — logs to pair with spans.
- Tracking Down a Hung await with Task Stacks — when a span never ends.
- Capturing Artifacts from a Failed CI Test Run — exporting traces as CI artefacts.
← Back to Logging and Observability for Debugging