Use when tests must prove a request produces the expected spans across services in a running Jaeger, Zipkin or Grafana Tempo backend. Runs the backend in CI, queries traces by service and operation, and asserts on spans, parent-child links and durations.

/plugin install qa-distributed-tracing@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

Jaeger ingests traces over OTLP and exposes a query API for verification. Per the Jaeger getting-started docs (opens in new window), the all-in-one image "combines collector and query components in a single process and uses a transient in-memory storage for trace data" - perfect for CI.

When to use

  • E2E or integration test exercises multiple services and you need to verify the full distributed trace shape (not just per-process spans).
  • Production observability stack uses Jaeger; tests should reflect the same query API your alerts/SLOs depend on.
  • Smoke test after instrumentation changes - confirm spans actually reach Jaeger (not just the SDK exporter).

How to use

  1. Start Jaeger all-in-one in CI as a Docker service (Step 1) - it exposes OTLP ingest on :4317/:4318 and the query API on :16686.
  2. Point the app's OpenTelemetry SDK at the collector's OTLP endpoint (Step 2).
  3. Exercise the flow, force_flush() the span processor, and let the ingest pipeline settle before querying (Worked example).
  4. Query GET /api/traces?service=X&operation=Y and assert on the returned span set and tags (Worked example); parent-child + duration assertions and the full query API live in references/query-api-and-ci-wiring.md.
  5. Scope each test to a unique service.name so shared-CI trace data does not cross-contaminate (see references).

Step 1 - Run Jaeger all-in-one in CI

Per the Jaeger getting-started docs (opens in new window):

docker run --rm --name jaeger \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  -p 5778:5778 \
  -p 9411:9411 \
  cr.jaegertracing.io/jaegertracing/jaeger:2.17.0
PortPurpose
16686Jaeger UI + query HTTP API
4317OTLP/gRPC ingest
4318OTLP/HTTP ingest

The full port map (sampling :5778, Zipkin :9411) and the GitHub Actions service block are in references/query-api-and-ci-wiring.md.

Step 2 - Configure SDK to ship to Jaeger

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

provider = TracerProvider()
provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True))
)
trace.set_tracer_provider(provider)

BatchSpanProcessor defers shipping, so the Worked example flushes manually before querying.

Worked example

Exercise the flow, force a flush, then query Jaeger and assert the span set plus a tag value end to end:

def test_order_trace_visible_in_jaeger():
    with use_tracer():
        create_order(items=[item])

    # Flush all spans to Jaeger before query
    trace.get_tracer_provider().force_flush(timeout_millis=5000)

    # Allow Jaeger ingest pipeline a moment
    time.sleep(0.5)

    resp = requests.get(
        "http://localhost:16686/api/traces",
        params={"service": "orders", "operation": "order.create", "lookback": "1m", "limit": 1},
    )
    traces = resp.json()["data"]
    assert len(traces) == 1
    span = next(s for s in traces[0]["spans"] if s["operationName"] == "order.create")
    tag = next(t for t in span["tags"] if t["key"] == "order.item_count")
    assert tag["value"] == 1

The force_flush + brief sleep is mandatory: BatchSpanProcessor batches exports, so an immediate query races the ingest pipeline and misses the span. For parent-child links and duration ceilings, see references/query-api-and-ci-wiring.md.

Other backends - Zipkin and Tempo

The same workflow (backend in Docker, ship OTLP, flush, query, assert) applies to the other two mainstream OSS backends; only the query surface changes:

  • Zipkin (Spring Cloud Sleuth heritage; B3 propagation) - REST API on :9411, string-typed tags, dependency-graph assertions, B3 single/multi-header tests: references/zipkin.md.
  • Grafana Tempo - TraceQL span selectors with structural operators (>> descendant, > child) that Jaeger's flat span list cannot express, /api/search + /api/traces/{id}: references/tempo.md.

Anti-patterns

Anti-patternWhy it failsFix
Query Jaeger immediately after exerciseSpans may not have shipped yetforce_flush() + brief sleep (Worked example)
Use prod Jaeger from CITest traces pollute prod dataAlways Docker all-in-one (Step 1)
Hard-code service name across testsCross-test contamination on shared CIUnique service.name per test (references)
Assume long retentionAll-in-one is in-memory; old traces evictedRestart container or shorten test runs
Skip flushing pipelineBatchSpanProcessor defers ship; queries miss spansAlways flush before query (Worked example)

Limitations

  • Jaeger v2 changed deployment + binary names from v1; verify current image tag at the Jaeger getting-started docs (opens in new window).
  • Storage backends (Cassandra, Elasticsearch, OpenSearch, Badger) matter for production but Docker all-in-one is sufficient for CI.
  • Jaeger UI is for humans; only query HTTP API in tests (no scraping HTML).

References

Reference

Jaeger query API, advanced assertions, and CI wiring

View source (opens in a new tab) (opens in new window)

Deep reference for the jaeger-trace-tests SKILL.md. Consult for the full Jaeger query API surface, parent-child + duration assertion patterns, the GitHub Actions service block, and per-test isolation / retention on a shared CI backend.

Query API endpoints

Jaeger exposes trace data over an HTTP query API on :16686:

EndpointReturns
GET /api/servicesList of service names
GET /api/services/{service}/operationsOperations for a service
GET /api/traces?service=X&operation=Y&lookback=5m&limit=10Trace JSON
GET /api/traces/{traceId}Single trace by ID

Trace JSON response shape (selected fields):

{
  "data": [{
    "traceID": "abc...",
    "spans": [
      {
        "spanID": "def...",
        "operationName": "order.create",
        "duration": 12345,
        "tags": [{"key": "order.item_count", "type": "int64", "value": 1}],
        "references": [{"refType": "CHILD_OF", "spanID": "parent..."}]
      }
    ]
  }]
}

duration is in microseconds; tags is a flat list of typed key/value pairs; parent links live in references, not on the child span directly.

Parent-child assertions via references

Jaeger encodes parent links as references with refType: "CHILD_OF".

def parent_id(span):
    refs = span.get("references", [])
    child_of = [r for r in refs if r["refType"] == "CHILD_OF"]
    return child_of[0]["spanID"] if child_of else None

assert parent_id(db_span) == order_span["spanID"]

Assert a duration ceiling straight off the microsecond duration field:

assert order_span["duration"] < 500_000  # under 500ms

GitHub Actions service

Run the all-in-one image as a job service so every step can reach OTLP ingest and the query API:

services:
  jaeger:
    image: cr.jaegertracing.io/jaegertracing/jaeger:2.17.0
    ports:
      - 16686:16686
      - 4317:4317
      - 4318:4318

Full port map: 16686 query API + UI, 4317 OTLP/gRPC ingest, 4318 OTLP/HTTP ingest, 5778 sampling config, 9411 Zipkin (B3) compatibility.

Per-test isolation on shared CI

CI runs many tests against one Jaeger. Scope each test with a unique service.name (or a unique trace tag) so a query never sees another test's spans:

service_name = f"orders-test-{uuid4()}"
# configure the SDK with this service name, then query filtered by it

In-memory storage is bounded by Jaeger's eviction; long test runs should restart the container or accept eviction.

Retention

All-in-one uses transient in-memory storage per the Jaeger getting-started docs (opens in new window). For longer runs mount a config or restart the container between workflows:

docker run ... \
  -v /path/to/config.yaml:/jaeger/config.yaml \
  cr.jaegertracing.io/jaegertracing/jaeger:2.17.0 \
  --config /jaeger/config.yaml

Production storage backends (Cassandra, Elasticsearch, OpenSearch, Badger) matter for real deployments; the in-memory all-in-one is sufficient for CI.

Reference

Grafana Tempo backend - TraceQL queries, same workflow

View source (opens in a new tab) (opens in new window)

Grafana Tempo ("an open source and high-scale distributed tracing backend", per Tempo getting started (opens in new window)) follows the same run-query-assert workflow, with two differences: queries use TraceQL, and structural operators give parent-child / descendant assertions Jaeger's flat span-list API does not expose natively.

Run single-binary in CI

Ports: 3200 (HTTP API + UI), 4317/4318 (OTLP ingest). Tempo needs a minimal tempo.yaml (per the configuration reference (opens in new window), monolithic target: all):

# tempo.yaml
server:
  http_listen_port: 3200
distributor:
  receivers:
    otlp:
      protocols:
        grpc: { endpoint: 0.0.0.0:4317 }
        http: { endpoint: 0.0.0.0:4318 }
storage:
  trace:
    backend: local
    local: { path: /var/tempo/traces }
docker run --rm --name tempo -p 3200:3200 -p 4317:4317 -p 4318:4318 \
  -v "$PWD/tempo.yaml:/etc/tempo.yaml" \
  grafana/tempo:latest -target=all -config.file=/etc/tempo.yaml

until curl -sf http://localhost:3200/ready; do sleep 1; done   # readiness gate

The SDK exporter config is identical to Jaeger's (OTLP to :4317).

TraceQL essentials

Per Construct a TraceQL query (opens in new window), span selectors use { }:

PrefixMeaningExample
span.Span attributespan.http.status_code
resource.Resource attributeresource.service.name
span:Intrinsic span fieldspan:status, span:duration, span:name, span:kind
trace:Trace intrinsictrace:duration, trace:rootService, trace:rootName

Operators: =, !=, >, >=, <, <=, =~ (regex, fully anchored - wrap with .* for partial match), !~; connectives &&, ||.

Structural operators assert span relationships: >> descendant, > direct child, << ancestor, ~ sibling:

{ span.http.url = "/checkout" } >> { span.db.system = "postgresql" }

Pipelines aggregate: { span:status = error } | count() > 1, { resource.service.name = "api" } | avg(span:duration) > 500ms.

Query via /api/search

Per the Tempo API docs (opens in new window), /api/search takes q (URL-encoded TraceQL), limit (default 20), start/end (epoch seconds), spss (spans per span-set, default 3), minDuration/maxDuration.

def test_checkout_span_reaches_tempo():
    with tracer.start_as_current_span("POST /order"):
        place_order(items=["widget"])
    trace.get_tracer_provider().force_flush(timeout_millis=5000)
    time.sleep(0.5)

    query = '{ resource.service.name = "checkout" && span.http.url = "/order" }'
    traces = requests.get("http://localhost:3200/api/search",
                          params={"q": query, "limit": 1}).json()["traces"]
    assert len(traces) == 1
    assert traces[0]["rootServiceName"] == "checkout"

Full trace by ID - parent-child assertions

GET /api/traces/{traceID} returns OpenTelemetry JSON; walk resourceSpans[].scopeSpans[].spans[] and assert db_span["parentSpanId"] == root_span["spanId"]. Attributes are { "key": ..., "value": { "<type>Value": ... } } objects (OTel proto format).

Tempo-specific anti-patterns

Anti-patternWhy it failsFix
Omit start/end on long CI runsSearches all backend blocks; slowEpoch bounds scoped to the test window
Span counts from /api/search with default spss=3spss caps spans per span-setFetch the full trace via /api/traces/{id}
Partial-match =~ without wrappingTraceQL regex is fully anchored=~ ".*substring.*"
Grafana UI as the assertion surfaceHTML scraping is fragile/api/search + /api/traces/{id}

Limitations

  • backend: local suits CI, not production (object storage recommended per the configuration reference).
  • TraceQL requires the Parquet block format (Tempo's default); legacy TSDB blocks don't support it.

References

Reference

Zipkin backend - same run-query-assert workflow

View source (opens in a new tab) (opens in new window)

Zipkin is the original distributed-tracing system (predates OpenTelemetry); still common in Java shops via Spring Cloud Sleuth heritage, and these tests protect a Zipkin → Jaeger/OTel cutover. The workflow is identical to Jaeger's: run the backend in CI, ship spans, force_flush(), query the REST API, assert on the span set.

Run in CI

Per the Zipkin quickstart (opens in new window):

docker run -d -p 9411:9411 openzipkin/zipkin
services:
  zipkin:
    image: openzipkin/zipkin
    ports: ["9411:9411"]

REST API

Per the Zipkin API spec (opens in new window):

EndpointReturns
GET /api/v2/servicesService names
GET /api/v2/spans?serviceName=XOperations
GET /api/v2/traces?serviceName=X&spanName=Y&lookback=300000&limit=10Traces (lookback in ms)
GET /api/v2/trace/{traceId}Single trace
GET /api/v2/dependencies?endTs=...&lookback=...Service dependency graph
POST /api/v2/spansSubmit spans (V2 JSON)

Ship + query + assert

from opentelemetry.exporter.zipkin.json import ZipkinExporter
# BatchSpanProcessor(ZipkinExporter(endpoint="http://localhost:9411/api/v2/spans"))

def test_order_trace_in_zipkin():
    with use_tracer():
        create_order(items=[item])
    trace.get_tracer_provider().force_flush(timeout_millis=5000)
    time.sleep(0.5)

    traces = requests.get(
        "http://localhost:9411/api/v2/traces",
        params={"serviceName": "orders", "spanName": "order.create",
                "lookback": 60000, "limit": 1},
    ).json()                       # list of lists of spans
    assert len(traces) == 1
    span = next(s for s in traces[0] if s["name"] == "order.create")
    assert span["tags"]["order.item_count"] == "1"   # Zipkin V2 tags are ALL strings

Zipkin V2 stores tag values as strings (vs Jaeger's typed tags) - cast in assertions accordingly.

B3 propagation header tests

Per the B3 propagation spec (opens in new window):

  • Multi-header: X-B3-TraceId (32/16 lower-hex), X-B3-SpanId (16), X-B3-ParentSpanId (absent on root), X-B3-Sampled (1/0), X-B3-Flags (1 debug).
  • Single-header: b3: {TraceId}-{SpanId}-{SamplingState}-{ParentSpanId}, sampling 1 accept / 0 deny / d debug / absent defer.

Test both forms - modern services increasingly send only the single-header form:

def test_b3_single_header_propagates():
    headers = {"b3": f"{trace_id}-{span_id}-1-{parent_id}"}
    requests.get("http://localhost:8080/orders", headers=headers)
    time.sleep(0.5)
    spans = requests.get(f"http://localhost:9411/api/v2/trace/{trace_id}").json()
    assert any(s["traceId"] == trace_id for s in spans)

Dependency-graph assertion

Zipkin computes service dependencies from observed traces; aggregation is lazy (in-memory computes inline; Cassandra uses Spark batch) - allow

=2s:

deps = requests.get("http://localhost:9411/api/v2/dependencies",
                    params={"endTs": int(time.time() * 1000), "lookback": 60000}).json()
pair = next((d for d in deps if d["parent"] == "orders" and d["child"] == "payments"), None)
assert pair and pair["callCount"] >= 1

Zipkin-specific anti-patterns

Anti-patternWhy it failsFix
Assert tag values as integersV2 tags are all stringsCompare as string
Test only multi-header B3Single-header form is commonTest both
Expect the dependency graph immediatelyAggregation is lazyAllow ≥2s delay

References