Skip to content

Jaeger

Who this page is for: in plain English, Jaeger stores and displays distributed traces - the timeline of one request as it hops across all the services that handled it. This page is production depth, aimed at readers already comfortable with Pods, Services, and Deployments. New to Kubernetes? Start with the Beginner track.

Jaeger is a CNCF graduated distributed tracing system, originally built at Uber to answer a question that logs and metrics can't answer well on their own: when a single user request fans out across dozens of microservices running in dozens of pods, which one is actually slow? Metrics tell you a service's p99 latency went up. Logs tell you what happened inside one service. Only a trace shows you the full causal path of one request as it crosses every service boundary, with timing at each hop.

Jaeger is on its v2 line as of 2026 - check the releases page for the current version - and the legacy v1 architecture reached end-of-life on December 31, 2025 and no longer receives updates. Jaeger v2 is built directly on the OpenTelemetry Collector framework rather than v1's bespoke pipeline - this guide describes the current v2 architecture, noting where it differs from the old v1 model you may still see referenced in older tutorials.

In a Kubernetes environment this matters more, not less, than in a monolith: a single HTTP request might traverse an ingress, a gateway, three backend services, a database proxy, and a cache, each in its own pod, possibly on different nodes, possibly retried, possibly fanned out in parallel. Jaeger reconstructs that request as a single tree you can inspect, and its UI's waterfall view makes the slow hop visually obvious in a way that grepping logs across five deployments never will.

Tracing fundamentals

A trace represents one end-to-end request. It's a tree of spans, where each span represents one unit of work - an RPC call, a database query, a function - with a start time, a duration, and a parent span (except the root span, which has none).

Trace: GET /checkout
└── span: api-gateway (12ms)
    └── span: checkout-service.CreateOrder (9ms)
        ├── span: inventory-service.Reserve (3ms)
        ├── span: payment-service.Charge (5ms)
        │   └── span: stripe-client.POST (4.5ms)
        └── span: order-db.INSERT (1ms)

Each span carries tags (key-value metadata, like http.status_code or db.statement) and logs (timestamped events within the span, like a retry or an error). What makes this a single trace rather than five unrelated spans is context propagation: when api-gateway calls checkout-service, it injects a trace ID and its own span ID into the outbound request headers (classically the uber-trace-id header for Jaeger's native format, or the W3C traceparent header in the now-standard OpenTelemetry propagation format). The receiving service extracts that context and creates its child span as part of the same trace. If any service in the chain doesn't propagate the header, the trace breaks into disconnected fragments - this is the single most common integration failure in real deployments.

Architecture

flowchart TD
    subgraph Apps[Instrumented Applications]
        SvcA[Service A\nOTel SDK]
        SvcB[Service B\nOTel SDK]
        SvcC[Service C\nOTel SDK]
    end
    subgraph Ingest
        OTelCol[Upstream OpenTelemetry\nCollector, optional]
        Collector[Jaeger Collector\nan OTel Collector build\nwith Jaeger exporters/extensions]
    end
    subgraph Storage
        Cassandra[(Cassandra)]
        ES[(Elasticsearch /\nOpenSearch)]
        Badger[(Badger\nall-in-one / dev)]
    end
    subgraph Query
        QuerySvc[Jaeger Query]
        UI[Jaeger UI]
    end

    SvcA --> |OTLP gRPC/HTTP| Collector
    SvcB --> |OTLP| OTelCol
    OTelCol --> |OTLP export| Collector
    SvcC --> |OTLP| Collector
    Collector --> Cassandra
    Collector --> ES
    Collector --> Badger
    QuerySvc --> Cassandra
    QuerySvc --> ES
    QuerySvc --> Badger
    UI --> QuerySvc

Components:

  • Collector - receives spans, validates and indexes them, writes to the storage backend. Since Jaeger v2, the collector is an OpenTelemetry Collector distribution: it's built on the upstream OTel Collector framework and configured with the standard OTel Collector YAML (receivers/processors/exporters/extensions), with Jaeger-specific components - storage exporters, the jaeger_storage extension, the jaeger_query extension - layered on top. It accepts OTLP natively over gRPC or HTTP; Jaeger's legacy jaeger.thrift formats are handled through compatibility receivers rather than being the native protocol.
  • jaeger-agent - this component no longer exists as of Jaeger v2. In the v1 architecture it was a per-node daemon that batched spans from Jaeger-native client SDKs before forwarding them to the collector; that role is now handled by OTLP export (directly to the Jaeger Collector, or via an intermediate OpenTelemetry Collector for batching/fan-out). If you're following an older tutorial that deploys jaeger-agent as a DaemonSet or sidecar, that guidance is v1-only and no longer applicable.
  • Query - serves the API that reads traces back out of storage. In v2 this runs as the jaeger_query extension inside the same Collector binary/process rather than a separate server, though it can still be deployed as its own component depending on your topology.
  • UI - the web frontend for searching traces and viewing the timeline/waterfall view, served by the Query extension.

The shift to OpenTelemetry

Jaeger originally shipped its own client libraries and wire format. That model has been superseded: instrumentation today is done with the vendor-neutral OpenTelemetry SDKs, and Jaeger's role has narrowed to being one of several possible tracing backends that OpenTelemetry data can land in - alongside Grafana Tempo, Zipkin-compatible stores, and commercial APM backends.

The modern path looks like: application code is instrumented with OpenTelemetry SDKs (auto-instrumentation agents or manual spans) → spans are exported via OTLP, either straight to the Jaeger Collector or via an intermediate OpenTelemetry Collector for batching, sampling, and fan-out to multiple backends → Jaeger stores and serves them through its Query API and UI. Jaeger v2 completes this convergence: it's built directly on the OpenTelemetry Collector framework rather than Jaeger's original bespoke pipeline, so the ingestion, processing, and export model is now the same OTel Collector model used across the rest of the ecosystem. Jaeger v2 has been the shipping, stable release since late 2024, and Jaeger v1 reached end-of-life on December 31, 2025 - so on any current deployment, "Jaeger" now means the OTel-Collector-based v2 architecture, not a transitional direction.

Practically, this means you should instrument new services with OpenTelemetry SDKs and semantic conventions (http.method, db.system, rpc.service, and so on) rather than Jaeger's legacy client libraries, and treat Jaeger as a storage-and-query backend that happens to also speak its own native protocol for backward compatibility.

Storage backends

Backend Use case Notes
Badger All-in-one / development Embedded key-value store, single process, no external dependency, not for production scale
Cassandra Production, high write throughput Mature, horizontally scalable, the original production backend at Uber's scale
Elasticsearch / OpenSearch Production, rich query/aggregation Popular when you already run ES/OpenSearch for logs; enables more flexible ad hoc queries
memory Testing / CI Ephemeral, lost on restart

For anything beyond local development or CI, choose Cassandra or Elasticsearch/OpenSearch based on what your team already operates. Running Jaeger's storage backend well (capacity planning, retention/index-rollover policy) is often the bulk of the real operational effort, not the tracing pipeline itself.

Sampling strategies

Tracing every request in a high-throughput service is usually neither necessary nor affordable - span volume scales with request volume, and storage/query cost scales with span volume. Sampling decides which traces get recorded.

Strategy Behavior
Probabilistic Sample a fixed percentage of traces (e.g. 1%), decided independently per trace
Rate limiting Sample at most N traces per second, regardless of overall traffic volume
Adaptive Collector-computed sampling rate that adjusts per service/endpoint to hit a target volume, so low-traffic operations aren't starved while high-traffic ones aren't oversampled
Per-service / per-operation overrides A specific service or operation gets its own sampling rate, distinct from the global default

Sampling decisions are typically made at the root span (head-based sampling) and propagated down so an entire trace is sampled or dropped consistently - a partially sampled trace with missing children is far less useful. Clients fetch sampling configuration from a central endpoint, so rates can be tuned without redeploying instrumented services. In Jaeger v2 this is served by the remote_sampling extension on the OTel Collector build (configured alongside the other receivers/exporters/extensions in the Collector's YAML config), which exposes the same Remote Sampling API that Jaeger clients and the OpenTelemetry SDKs' jaegerremote sampler poll. Adaptive sampling additionally requires a sampling_store backend (memory, Cassandra, Badger, or Elasticsearch/OpenSearch) to track observed traffic and compute per-service/per-endpoint rates. The strategy document itself still looks like this:

{
  "service_strategies": [
    {
      "service": "checkout-service",
      "type": "probabilistic",
      "param": 1.0
    },
    {
      "service": "payment-service",
      "type": "ratelimiting",
      "param": 50
    }
  ],
  "default_strategy": {
    "type": "probabilistic",
    "param": 0.01
  }
}

Always sample errors and unusually slow requests at a higher rate than routine traffic - that's the traffic you actually need traces for.

Jaeger UI

The UI's core workflows:

  • Search - find traces by service, operation, tags, duration range, and time window.
  • Trace timeline / waterfall view - each span rendered as a horizontal bar positioned by start time and sized by duration, nested under its parent. This is where latency bottlenecks jump out visually: a wide bar low in the tree is the slow call; large gaps between a parent span's start and its child's start reveal queueing or network latency rather than the child's own processing time.
  • Compare - diff two traces side by side to spot what changed between a fast and a slow run of the same operation.
  • Service dependency graph - derived from observed span parent/child relationships across all traces, showing which services call which.

Deploying on Kubernetes

The old jaegertracing/jaeger-operator (the one that managed deployments via a jaegertracing.io/v1 Jaeger custom resource) is deprecated - it was v1-only, and with the v1 architecture retired at the end of 2025 the project has marked that repository deprecated. There is no production/allInOne strategy field to set anymore; that was a v1-operator concept.

For Jaeger v2, the two supported ways to deploy on Kubernetes are:

  • The OpenTelemetry Operator - since Jaeger v2's Collector is itself an OpenTelemetry Collector distribution, the general-purpose OTel Operator can manage it like any other Collector, via an OpenTelemetryCollector custom resource pointed at a Jaeger-flavored config (with the jaeger_storage and jaeger_query extensions enabled).
  • The Jaeger Helm chart (jaegertracing/jaeger) - installs the v2 Collector/Query components directly as Deployments, without requiring an operator at all.

Either way, the actual Jaeger-specific configuration is expressed as an OTel Collector config (receivers, processors, exporters, extensions) rather than a bespoke CRD spec. A representative production config, run through the Collector binary, looks like:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

extensions:
  jaeger_storage:
    backends:
      primary_storage:
        elasticsearch:
          server_urls: [https://elasticsearch.observability.svc:9200]
          index_prefix: jaeger
  jaeger_query:
    storage:
      traces: primary_storage
  remote_sampling:
    storage:
      samplingstore: primary_storage
    file: /etc/jaeger/sampling-strategies.json

exporters:
  jaeger_storage_exporter:
    trace_storage: primary_storage

service:
  extensions: [jaeger_storage, jaeger_query, remote_sampling]
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [jaeger_storage_exporter]
kubectl create namespace observability

# via the Jaeger Helm chart
helm repo add jaegertracing https://jaegertracing.github.io/helm-charts
helm install prod-jaeger jaegertracing/jaeger \
  --namespace observability \
  --set-file collector.config=jaeger-v2-config.yaml

kubectl get pods -n observability -l app.kubernetes.io/instance=prod-jaeger

Elasticsearch-backed Jaeger still accumulates one index per day by default, so a production deployment still needs an index-cleaner/retention job (the jaeger_storage extension's ES config or a scheduled esIndexCleaner job, depending on how you deploy) or unbounded index growth on the cluster.

Instrumenting applications

With OpenTelemetry, point the SDK's OTLP exporter at the collector's OTLP endpoint:

# Example: environment variables for an OTel-instrumented app
env:
  - name: OTEL_EXPORTER_OTLP_ENDPOINT
    value: "http://prod-jaeger-collector.observability.svc:4317"
  - name: OTEL_TRACES_SAMPLER
    value: "parentbased_traceidratio"
  - name: OTEL_TRACES_SAMPLER_ARG
    value: "0.05"
  - name: OTEL_SERVICE_NAME
    value: "checkout-service"

For languages/frameworks with OpenTelemetry auto-instrumentation, this environment-variable-only setup gets you request/response spans, DB client spans, and outbound HTTP spans with no code changes, using OTel's semantic conventions for span naming and tags.

Span tags, logs, and semantic conventions

Tags and logs are what make a trace useful beyond raw timing. A tag is a single key-value attribute attached to the whole span (http.status_code=500, db.statement="SELECT ...", peer.service="payment-service"); a log is a timestamped event within the span's duration (a retry attempt, a cache miss, a validation error), analogous to a structured log line but scoped to that specific unit of work.

Early Jaeger deployments tended to invent ad hoc tag names per team, which made cross-service queries and dashboards inconsistent - one service tagged status_code, another http_status, another httpStatusCode. OpenTelemetry's semantic conventions solve this by standardizing tag names across languages and frameworks:

Convention prefix Example tags Applies to
http.* http.request.method, http.response.status_code, url.path HTTP client/server spans
db.* db.system, db.statement, db.operation.name Database client spans
rpc.* rpc.system, rpc.service, rpc.method gRPC and other RPC calls
messaging.* messaging.system, messaging.destination.name Queue/broker producers and consumers
error / exception.* error=true, exception.type, exception.message Any span that failed

Auto-instrumentation libraries apply these conventions automatically; when adding manual spans, matching the convention rather than inventing a new tag name keeps traces queryable the same way across every service, and keeps Jaeger's UI filters (which key off common tag names) actually useful.

Correlating traces with logs and metrics

A trace tells you where time went; it rarely tells you the full why on its own. The standard pattern is to inject the active trace ID into structured application logs, so an engineer looking at a slow trace in the Jaeger UI can pivot straight to the exact log lines emitted during that request:

{"level": "error", "msg": "payment gateway timeout", "trace_id": "6c614b8f1c1f4b2a9e3d5f7a2b1c0d4e", "span_id": "a1b2c3d4e5f60718", "service": "payment-service"}

OpenTelemetry SDKs expose the current trace ID and span ID through the logging integration for most languages, so this correlation can be automatic rather than hand-wired. Similarly, exemplars - trace IDs attached to specific Prometheus histogram buckets - let you jump from "this latency bucket spiked" directly to a representative trace that landed in that bucket, closing the loop between metrics (Prometheus) and traces (Jaeger) without manual correlation.

Troubleshooting broken or missing traces

The most common Jaeger problems in practice aren't in Jaeger itself - they're upstream, in how spans get produced and propagated:

  • Trace suddenly stops at a service boundary. Almost always a missing propagation header. Check that the outbound HTTP client, gRPC interceptor, or message broker producer is wrapped with OpenTelemetry instrumentation, not called with a raw, uninstrumented client that drops the traceparent header.
  • Spans exist but never resolve into a trace in the UI. Usually a sampling mismatch - a parent span was sampled but a child service is running with OTEL_TRACES_SAMPLER=always_off or a much lower ratio and independently decided not to sample, producing an orphaned fragment. Head-based sampling decisions need to propagate down, not be re-decided per service.
  • Collector rejecting spans. Check collector logs for OTLP payload size limits or storage backend backpressure (Elasticsearch bulk rejection, Cassandra write timeouts) - this shows up as dropped spans with no obvious client-side error.
  • UI query timeouts on high-cardinality searches. Searching by a tag value with very high cardinality (a raw user ID, a request UUID used as a tag rather than the trace ID itself) forces an expensive scan on Elasticsearch/Cassandra-backed deployments. Search by service + operation + time range first, then narrow.

Common mistakes

Mistake Consequence
Missing context propagation across a service boundary Trace fragments into disconnected pieces; the UI shows a broken or incomplete tree
Sampling at 100% in production without capacity planning Storage backend (especially Elasticsearch) falls over under span volume
Running all-in-one in production No horizontal scaling, in-memory/Badger storage loses data on restart
Mixing Jaeger-native client instrumentation and OTel SDKs inconsistently Inconsistent span naming/tags make cross-service traces harder to correlate
No index lifecycle management on Elasticsearch storage Disk usage grows unbounded; query performance degrades over time
Treating traces as a log replacement Traces show causal timing across services; they're not a substitute for structured logs or metrics, they complement them
  • OpenTelemetry - how spans get produced and shipped to Jaeger in the first place
  • Prometheus - the metrics half of the picture, and the source of exemplars that link to traces
  • Istio - mesh-generated spans, and the header propagation they depend on
  • Troubleshooting - what to check when traces don't show up