Testland
Browse all skills & agents

jaeger-trace-tests

Author integration tests that query a tracing backend for cross-service trace verification - Jaeger, Zipkin, or Grafana Tempo, same run-query-assert workflow. Jaeger all-in-one Docker for CI (OTLP gRPC :4317 + HTTP :4318 ingest, query API on :16686), `/api/traces?service=X&operation=Y` query patterns, span set + parent-child + duration assertions; Zipkin (:9411 REST API, B3 single/multi-header propagation tests, dependency graph) in references/zipkin.md; Tempo (TraceQL span selectors + structural operators, /api/search, single-binary Docker) in references/tempo.md. Use when verifying that a request produces the expected spans across service boundaries in a running Jaeger, Zipkin, or Tempo backend.

Install with skills.sh (any agent)

npx skills add testland/qa --skill jaeger-trace-tests
View source

jaeger-trace-tests

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

Jaeger query API, advanced assertions, and CI wiring

View source (opens in new window)

Jaeger query API, advanced assertions, and CI wiring

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.

Grafana Tempo backend - TraceQL queries, same workflow

View source (opens in new window)

Grafana Tempo backend - TraceQL queries, same workflow

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

Zipkin backend - same run-query-assert workflow

View source (opens in new window)

Zipkin backend - same run-query-assert workflow

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

Related skills

opentelemetry-trace-assertions

Author trace-shape assertions in tests using OpenTelemetry SDK in-memory exporter - capture spans during test execution, assert on span name + attributes + status + parent-child structure + duration. Cross-language patterns (Python `InMemorySpanExporter` + `SimpleSpanProcessor`, JS `getRecordedSpans()`, Java `OpenTelemetryExtension`); CI integration. Use when a service is instrumented with the OpenTelemetry SDK and downstream alerts, SLOs, or dashboards depend on specific span names or attributes that a refactor could silently drop.

otel-collector-config-tester

Validates OpenTelemetry Collector pipeline configurations and verifies spans flow end-to-end through the collector: runs `otelcol validate --config`, wires the `debug`/`file` exporter for span-output assertions, and integrates the full cycle into CI. Use when a collector config change (new receiver, processor swap, exporter wiring) needs correctness verification before deployment.

trace-spec-author

Build a trace specification document per feature - defines the trace shape (root span + child spans + key attributes per OpenTelemetry semantic conventions) that production code MUST emit. The spec drives both implementation reviews AND trace-assertion tests, so a single declarative document is the source of truth for what observability "looks like" for a feature. Use before instrumenting a new feature, when existing spans have grown organically with no agreed shape, or after an incident where a debugging session stalled on missing span attributes.