← Recipes

阅读中文版本

Assert Spans in Trace Tests

Use an in-memory span exporter to assert span names, parent-child links, attributes, and error status for a single request.

~35 mintestingopentelemetryobservability

Task

Verify that a request produces the expected trace tree and attributes, instead of eyeballing traces after an incident.

When to use this

Tracing is enabled, but a refactor stopped propagating context across an async boundary. Traces now show two disconnected roots, and nobody notices until a latency investigation.

Prerequisites

  • Node.js 22 with @opentelemetry/sdk-trace-node
  • @opentelemetry/sdk-trace-base and @opentelemetry/api
  • A test runner that can import a setup file before the application

Steps

1. Register a provider with an in-memory exporter so finished spans are inspectable.

import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';

export function testTracing() {
  const exporter = new InMemorySpanExporter();
  const provider = new NodeTracerProvider();
  provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
  provider.register();
  return { exporter, provider };
}

Expected output: Spans are exported synchronously into memory and available immediately after the request finishes.

2. Find the request span by its route attribute rather than by instrumentation-specific names.

const { exporter } = testTracing();
await request('/api/orders/42');
const spans = exporter.getFinishedSpans();
const server = spans.find((s) => s.attributes['http.route'] === '/api/orders/:id');
const db = spans.find((s) => s.name === 'db.query orders');
assert.ok(server, 'server span missing');
assert.ok(db, 'database span missing');

Expected output: Both the server span and the application's database span are present in the finished set.

3. Assert the parent-child relationship and the attributes dashboards filter on.

assert.equal(db.parentSpanContext?.spanId, server.spanContext().spanId);
assert.equal(db.attributes['db.system'], 'postgresql');
assert.equal(server.attributes['http.response.status_code'], 200);

Expected output: The database span is a child of the request span and carries db.system and status attributes.

4. Send a W3C traceparent and confirm the service continues the incoming trace.

const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
exporter.reset();
await request('/api/orders/42', { headers: { traceparent } });
const root = exporter.getFinishedSpans()
  .find((s) => s.attributes['http.route'] === '/api/orders/:id');
assert.equal(root.spanContext().traceId, '4bf92f3577b34da6a3ce929d0e0e4736');

Expected output: The trace id matches the caller's, proving context propagation works across the HTTP boundary.

5. Force a dependency failure and assert the span is marked as an error.

exporter.reset();
await request('/api/orders/42?fail=db');
const failed = exporter.getFinishedSpans()
  .find((s) => s.name === 'db.query orders' && s.status.code === SpanStatusCode.ERROR);
assert.ok(failed);
assert.ok(failed.events.some((e) => e.name === 'exception'));

Expected output: The failing database span has ERROR status and records an exception event with the error message.

Troubleshooting

Problem

No spans are exported

Solution

The provider was registered after the instrumented modules were imported. Register it in a setup file loaded first, for example with node --import.

Problem

Spans appear as separate roots

Solution

Context is lost across an async boundary or a queue. Propagate context explicitly or enable the async hooks instrumentation.

Problem

Only some spans arrive

Solution

A BatchSpanProcessor was used without flushing. Use SimpleSpanProcessor in tests, or call forceFlush before asserting.

Limitations

  • The in-memory exporter only sees spans from this process; downstream services are invisible.
  • Span names and attributes depend on instrumentation versions, so assert your own route and span contracts.
  • Tracing changes timing; never assert span durations in functional tests.

A broken trace is invisible until you need it

Tracing fails quietly. A missing context propagation, a provider registered too late, or a sampler that drops everything produces traces that look plausible in the UI but are disconnected or incomplete. Nobody notices during normal operation, and the gap only surfaces during an incident, when it costs the most. A trace test makes the expected shape explicit and fails in CI instead.

Assert the tree, not the screenshot

A useful trace test answers three questions: does the request produce a root span, are the application spans attached to it, and do the important attributes exist? Find the root by http.route rather than by the instrumented span name, which changes between instrumentation versions. Then assert parent ids and a small set of attributes that dashboards and alerts rely on.

assert.equal(db.parentSpanContext?.spanId, server.spanContext().spanId);
assert.equal(db.attributes['db.system'], 'postgresql');

Verify propagation from the caller

Distributed tracing only works if the incoming traceparent is honored and the outgoing requests carry it forward. Send a known trace id in the request and assert the exported span uses the same id. For outgoing calls, assert that the downstream mock received a traceparent header with that trace id. This catches the common regression where a custom HTTP client bypasses instrumentation.

Test the error path

Span status is what turns a trace into a diagnostic tool. When a dependency fails, the span should be ERROR and include an exception event with the error type and message. Assert that explicitly, because it is easy to catch and swallow an error in a retry loop, leaving a successful-looking span that hides the failure that triggered the retry.

Keep the setup honest

Use a dedicated setup file that registers the provider before the application is imported, and shut the provider down after the suite. Do not register multiple global providers; the second registration is ignored and tests silently assert against the wrong exporter. If a test needs a different sampler or exporter, create a new provider and pass a tracer explicitly instead of re-registering globally.