Zipkin vs Jaeger for Testing and Debugging Microservices

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:latest

Both 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-zipkin
const { 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 Zipkin

For 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 names

Zipkin 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}/operations

Jaeger 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 backends

Your 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 traces

Run 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.

Read more