Zipkin vs Jaeger for Testing and Debugging Microservices
When you're adding distributed tracing to your test environment, Zipkin and Jaeger are the two open-source tools you'll evaluate. Both capture traces. Both work with OpenTelemetry. The differences matter when you're writing test assertions and running traces in CI.
Quick Comparison
| Feature | Zipkin | Jaeger |
|---|---|---|
| CNCF project | No | Yes (graduated) |
| Default storage | In-memory | In-memory |
| REST API | Simple, stable | Richer, paginated |
| All-in-one Docker | Yes | Yes |
| UI | Minimal, fast | Full-featured |
| Sampling support | Yes | Yes |
| OpenTelemetry support | Via collector | Native OTLP |
| Best for | Simple setups, small teams | Production-grade, complex queries |
Running Both in Docker
# Zipkin
docker run -d -p 9411:9411 openzipkin/zipkin
<span class="hljs-comment"># Jaeger
docker run -d \
-e COLLECTOR_OTLP_ENABLED=<span class="hljs-literal">true \
-p 16686:16686 \
-p 4317:4317 \
-p 4318:4318 \
jaegertracing/all-in-one:latestBoth start in under 5 seconds. In CI, this difference is irrelevant.
Sending Traces to Zipkin
Zipkin uses its own wire format (B3 headers) but also supports OpenTelemetry via the collector:
# Option 1: Zipkin native exporter
npm install @opentelemetry/exporter-zipkinconst { ZipkinExporter } = require('@opentelemetry/exporter-zipkin');
const exporter = new ZipkinExporter({ url: 'http://localhost:9411/api/v2/spans' });# Option 2: OTLP → Zipkin via OTel Collector
<span class="hljs-comment"># Set endpoint to your collector, collector forwards to ZipkinFor Jaeger with OTLP (simpler in most cases):
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const exporter = new OTLPTraceExporter({ url: 'http://localhost:4318/v1/traces' });REST API Differences
This is where the tools diverge most for test assertions.
Zipkin API
GET /api/v2/services — list services
GET /api/v2/traces?serviceName=order-service — search traces
GET /api/v2/trace/{traceId} — get trace
GET /api/v2/spans?serviceName=order-service — list operation namesZipkin returns a flat list of spans per trace — simpler, no nesting:
// Zipkin trace response
[
{
"traceId": "abc123",
"id": "span1",
"parentId": "root1",
"name": "get-inventory",
"timestamp": 1716000000000000, // microseconds
"duration": 45000, // microseconds
"localEndpoint": { "serviceName": "inventory-service" },
"tags": { "http.status_code": "200" }
}
]Jaeger API
GET /api/services
GET /api/traces?service=order-service
GET /api/traces/{traceId}
GET /api/services/{service}/operationsJaeger returns a richer structure with process metadata:
// Jaeger trace response
{
"data": [{
"traceID": "abc123",
"spans": [{
"traceID": "abc123",
"spanID": "span1",
"operationName": "get-inventory",
"duration": 45000, // microseconds
"processID": "p1",
"tags": [{ "key": "http.status_code", "type": "int64", "value": 200 }]
}],
"processes": {
"p1": { "serviceName": "inventory-service" }
}
}]
}Jaeger's tag format ([{ key, type, value }]) is more verbose than Zipkin's map format ({ key: value }) but supports typed values.
Test Helper Implementations
Zipkin Helper
// test-utils/zipkin.js
const ZIPKIN_API = 'http://localhost:9411/api/v2';
async function findRecentTraces(service, name, sinceMs) {
const params = new URLSearchParams({
serviceName: service,
spanName: name,
endTs: Date.now(),
lookback: Date.now() - sinceMs,
limit: 10,
});
const res = await fetch(`${ZIPKIN_API}/traces?${params}`);
return res.json();
}
function analyzeTrace(spans) {
return {
services: [...new Set(spans.map(s => s.localEndpoint?.serviceName))],
hasErrors: spans.some(s => s.tags?.error === 'true'),
maxDurationMs: Math.max(...spans.map(s => s.duration / 1000)),
};
}
module.exports = { findRecentTraces, analyzeTrace };Jaeger Helper
// test-utils/jaeger.js
const JAEGER_API = 'http://localhost:16686/api';
async function findRecentTraces(service, operation, sinceMs) {
const params = new URLSearchParams({
service,
operation,
start: (sinceMs * 1000).toString(),
limit: 10,
});
const res = await fetch(`${JAEGER_API}/traces?${params}`);
const json = await res.json();
return json.data || [];
}
function analyzeTrace(trace) {
const processMap = {};
Object.entries(trace.processes).forEach(([k, v]) => {
processMap[k] = v.serviceName;
});
const spans = trace.spans.map(s => ({
operation: s.operationName,
service: processMap[s.processID],
durationMs: s.duration / 1000,
hasError: s.tags.some(t => t.key === 'error' && t.value),
tags: Object.fromEntries(s.tags.map(t => [t.key, t.value])),
}));
return {
services: [...new Set(spans.map(s => s.service))],
hasErrors: spans.some(s => s.hasError),
maxDurationMs: Math.max(...spans.map(s => s.durationMs)),
spans,
};
}
module.exports = { findRecentTraces, analyzeTrace };Which Should You Use?
Choose Zipkin when:
- You already use B3 headers (Spring Cloud, older Brave instrumentation)
- Your team wants the simplest possible setup
- You have a small number of services and don't need advanced querying
- You prefer a minimal UI
Choose Jaeger when:
- You're starting fresh with OpenTelemetry (OTLP support is native)
- You need rich search (tags, duration range, service graphs)
- You have many services and need root cause analysis UI
- You're planning to scale to production (Jaeger has Cassandra/Elasticsearch backends)
For new projects starting with OpenTelemetry: Jaeger. For projects with existing Spring/Brave tracing: Zipkin until migration is complete.
Using the OTel Collector as an Abstraction
If you want to switch between backends without changing app code, route through the OTel Collector:
# otel-collector.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
exporters:
zipkin:
endpoint: "http://zipkin:9411/api/v2/spans"
jaeger:
endpoint: "jaeger:14250"
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
exporters: [zipkin] # swap to [jaeger] to change backendsYour app always exports to localhost:4318. The collector decides where traces go.
Running Both in CI for Migration
During a Zipkin → Jaeger migration, export to both simultaneously:
service:
pipelines:
traces:
receivers: [otlp]
exporters: [zipkin, jaeger] # both receive all tracesRun your trace assertion tests against Jaeger while keeping Zipkin for the existing dashboards.
Summary
Both Zipkin and Jaeger work well for test-environment tracing. The decision is mostly about:
- API preference — Zipkin's simpler flat format vs Jaeger's richer typed format
- Instrumentation — existing B3 headers (Zipkin) vs fresh OTLP setup (Jaeger)
- Future scale — Jaeger is better prepared for production growth
For most greenfield projects with OpenTelemetry: start with Jaeger. The OTLP support is native, the UI is more capable, and the REST API is expressive enough to power comprehensive test assertions.