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-testsjaeger-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
How to use
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| Port | Purpose |
|---|---|
| 16686 | Jaeger UI + query HTTP API |
| 4317 | OTLP/gRPC ingest |
| 4318 | OTLP/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"] == 1The 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:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Query Jaeger immediately after exercise | Spans may not have shipped yet | force_flush() + brief sleep (Worked example) |
| Use prod Jaeger from CI | Test traces pollute prod data | Always Docker all-in-one (Step 1) |
| Hard-code service name across tests | Cross-test contamination on shared CI | Unique service.name per test (references) |
| Assume long retention | All-in-one is in-memory; old traces evicted | Restart container or shorten test runs |
| Skip flushing pipeline | BatchSpanProcessor defers ship; queries miss spans | Always flush before query (Worked example) |
Limitations
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:
| Endpoint | Returns |
|---|---|
GET /api/services | List of service names |
GET /api/services/{service}/operations | Operations for a service |
GET /api/traces?service=X&operation=Y&lookback=5m&limit=10 | Trace 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 500msGitHub 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:4318Full 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 itIn-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.yamlProduction 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 gateThe 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 { }:
| Prefix | Meaning | Example |
|---|---|---|
span. | Span attribute | span.http.status_code |
resource. | Resource attribute | resource.service.name |
span: | Intrinsic span field | span:status, span:duration, span:name, span:kind |
trace: | Trace intrinsic | trace: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-pattern | Why it fails | Fix |
|---|---|---|
Omit start/end on long CI runs | Searches all backend blocks; slow | Epoch bounds scoped to the test window |
Span counts from /api/search with default spss=3 | spss caps spans per span-set | Fetch the full trace via /api/traces/{id} |
Partial-match =~ without wrapping | TraceQL regex is fully anchored | =~ ".*substring.*" |
| Grafana UI as the assertion surface | HTML scraping is fragile | /api/search + /api/traces/{id} |
Limitations
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/zipkinservices:
zipkin:
image: openzipkin/zipkin
ports: ["9411:9411"]REST API
Per the Zipkin API spec (opens in new window):
| Endpoint | Returns |
|---|---|
GET /api/v2/services | Service names |
GET /api/v2/spans?serviceName=X | Operations |
GET /api/v2/traces?serviceName=X&spanName=Y&lookback=300000&limit=10 | Traces (lookback in ms) |
GET /api/v2/trace/{traceId} | Single trace |
GET /api/v2/dependencies?endTs=...&lookback=... | Service dependency graph |
POST /api/v2/spans | Submit 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 stringsZipkin 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):
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"] >= 1Zipkin-specific anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Assert tag values as integers | V2 tags are all strings | Compare as string |
| Test only multi-header B3 | Single-header form is common | Test both |
| Expect the dependency graph immediately | Aggregation is lazy | Allow ≥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.