Jaeger Distributed Tracing: Setup and Testing Tutorial

Jaeger Distributed Tracing: Setup and Testing Tutorial

Jaeger is CNCF's open-source distributed tracing platform. It stores, indexes, and visualizes traces — and its REST API makes it a natural assertion target for integration tests. This tutorial walks from zero to running trace assertions.

Why Jaeger for Testing

Jaeger is popular in test environments because:

  • Single-binary all-in-one mode — no Kafka, no Cassandra, just one Docker image
  • REST query API — queryable from any test runner
  • In-memory storage option — clean state between test runs
  • Well-documented trace data model

Running Jaeger Locally

docker run -d --name jaeger \
  -e COLLECTOR_OTLP_ENABLED=true \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  jaegertracing/all-in-one:latest
Port Purpose
16686 Jaeger UI + REST API
4317 OTLP gRPC receiver
4318 OTLP HTTP receiver

Open http://localhost:16686 to see the Jaeger UI.

Instrumenting a Node.js App

npm install @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http
// tracing.js — load before anything else
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

Start your app with: node -r ./tracing.js server.js

Instrumenting a Python Service

pip install opentelemetry-sdk opentelemetry-exporter-otlp
# tracing.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter

provider = TracerProvider()
exporter = OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces")
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)

The Jaeger Query API

Jaeger exposes traces via HTTP at http://localhost:16686/api:

GET /api/services                          — list all services
GET /api/traces?service=order-service      — search traces by service
GET /api/traces/{traceId}                  — get specific trace
GET /api/services/{service}/operations     — list operations for a service

Searching Traces

async function findRecentTraces(service, operation, sinceMs) {
  const params = new URLSearchParams({
    service,
    operation,
    start: (sinceMs * 1000).toString(), // Jaeger uses microseconds
    limit: '10',
  });
  const res = await fetch(`http://localhost:16686/api/traces?${params}`);
  const json = await res.json();
  return json.data || [];
}

Extracting Spans

function extractSpans(trace) {
  const processMap = {};
  trace.processes.forEach((proc, key) => {
    processMap[key] = proc.serviceName;
  });
  
  return trace.spans.map(span => ({
    traceId: span.traceID,
    spanId: span.spanID,
    operation: span.operationName,
    service: processMap[span.processID],
    startMs: span.startTime / 1000,
    durationMs: span.duration / 1000,
    tags: Object.fromEntries(span.tags.map(t => [t.key, t.value])),
    logs: span.logs,
    hasError: span.tags.some(t => t.key === 'error' && t.value === true),
  }));
}

Writing Integration Tests

// jest.config.js — run Jaeger in setup
module.exports = {
  globalSetup: './test/jaeger-setup.js',
  globalTeardown: './test/jaeger-teardown.js',
};
// test/jaeger-setup.js
const { execSync } = require('child_process');

module.exports = async () => {
  execSync('docker run -d --name jaeger-test \
    -e COLLECTOR_OTLP_ENABLED=true \
    -p 16686:16686 -p 4317:4317 -p 4318:4318 \
    jaegertracing/all-in-one:latest');
  
  // wait for Jaeger to be ready
  await waitForJaeger('http://localhost:16686');
};
// order.test.js
const { findRecentTraces, extractSpans } = require('./jaeger-client');

describe('Order service traces', () => {
  let startedAt;

  beforeEach(() => {
    startedAt = Date.now();
  });

  test('successful order creates complete multi-service trace', async () => {
    // Act
    const res = await fetch('http://localhost:3000/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId: 'u1', sku: 'WIDGET-1', qty: 2 }),
    });
    expect(res.status).toBe(201);

    await new Promise(r => setTimeout(r, 800)); // wait for trace export

    // Assert on trace
    const traces = await findRecentTraces('order-service', 'POST /orders', startedAt);
    expect(traces).toHaveLength(1);

    const spans = extractSpans(traces[0]);
    const services = [...new Set(spans.map(s => s.service))];

    expect(services).toContain('order-service');
    expect(services).toContain('inventory-service');
    expect(services).toContain('payment-service');
    
    const errorSpans = spans.filter(s => s.hasError);
    expect(errorSpans).toHaveLength(0);
  });

  test('database spans have correct attributes', async () => {
    await fetch('http://localhost:3000/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId: 'u1', sku: 'WIDGET-1', qty: 1 }),
    });

    await new Promise(r => setTimeout(r, 800));

    const traces = await findRecentTraces('order-service', 'POST /orders', startedAt);
    const spans = extractSpans(traces[0]);
    const dbSpans = spans.filter(s => s.tags['db.system']);

    expect(dbSpans.length).toBeGreaterThan(0);
    dbSpans.forEach(span => {
      expect(span.tags['db.system']).toBeDefined();
      expect(span.durationMs).toBeLessThan(200);
    });
  });

  test('payment failure appears as error span', async () => {
    // Arrange: inject failure via header
    await fetch('http://localhost:3000/orders', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Test-Payment-Fail': 'card_declined',
      },
      body: JSON.stringify({ userId: 'u1', sku: 'WIDGET-1', qty: 1 }),
    });

    await new Promise(r => setTimeout(r, 800));

    const traces = await findRecentTraces('order-service', 'POST /orders', startedAt);
    const spans = extractSpans(traces[0]);
    const paymentSpan = spans.find(s => s.service === 'payment-service');

    expect(paymentSpan).toBeDefined();
    expect(paymentSpan.hasError).toBe(true);
    expect(paymentSpan.tags['error.message']).toContain('card_declined');
  });
});

Resetting State Between Test Runs

Jaeger's all-in-one uses in-memory storage by default — restarting the container clears all traces. For test suites:

// Clear by restarting Jaeger between test files
beforeAll(async () => {
  execSync('docker restart jaeger-test');
  await waitForJaeger('http://localhost:16686');
});

Alternatively, use time-based filtering (start parameter) to scope queries to the current test run.

Useful Jaeger Queries for Testing

// All services that ever sent a trace
const services = await fetch('http://localhost:16686/api/services')
  .then(r => r.json())
  .then(d => d.data);

// Traces with errors only
const errorTraces = await findRecentTraces('order-service', '', startedAt)
  .then(traces => traces.filter(t => 
    t.spans.some(s => s.tags.some(tag => tag.key === 'error' && tag.value))
  ));

// Slowest traces (for performance regression detection)
const sortedByDuration = traces.sort((a, b) => {
  const durationA = Math.max(...a.spans.map(s => s.startTime + s.duration)) - Math.min(...a.spans.map(s => s.startTime));
  const durationB = Math.max(...b.spans.map(s => s.startTime + s.duration)) - Math.min(...b.spans.map(s => s.startTime));
  return durationB - durationA;
});

Docker Compose Setup for Full Stack Tests

# docker-compose.test.yml
version: '3.8'
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    environment:
      - COLLECTOR_OTLP_ENABLED=true
    ports:
      - "16686:16686"
      - "4317:4317"
      - "4318:4318"

  order-service:
    build: ./services/order
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces
      - OTEL_SERVICE_NAME=order-service
    depends_on:
      - jaeger

  inventory-service:
    build: ./services/inventory
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces
      - OTEL_SERVICE_NAME=inventory-service
    depends_on:
      - jaeger

Summary

Jaeger makes trace-based testing practical:

  1. Start all-in-one — single Docker command, no configuration
  2. Instrument services with OpenTelemetry OTLP exporter
  3. Run a scenario (HTTP request, CLI command, background job)
  4. Wait 500ms–1s for async export
  5. Query localhost:16686/api/traces and assert on span structure, service participation, error flags, and latency

The result is an integration test that verifies not just the HTTP response, but the entire distributed execution path behind it.

Read more