Distributed tracing answers a question metrics cannot: for this specific slow request, where did the time go? Dashboards tell you the p99 doubled. A trace tells you it doubled because the auth service started making three sequential calls where it used to make one.
OpenTelemetry is how you produce traces. Jaeger is where you look at them.
Three moving parts
Instrumentation in your application produces spans. Either auto-instrumentation, which patches known libraries, or manual spans you add.
The Collector receives spans, processes them (batching, sampling, enrichment) and exports them onward. It is optional in theory and essential in practice.
Jaeger stores traces and provides the UI. Modern Jaeger accepts OTLP directly, so you no longer need Jaeger-specific client libraries or agents. Jaeger v2 is itself built on the OpenTelemetry Collector.
The thing to internalise: OpenTelemetry is a vendor-neutral wire format and SDK. Instrument once, change backends by editing Collector config. That property is the main reason to adopt it.
Auto-instrumentation on Kubernetes
Install the OpenTelemetry Operator, then declare an Instrumentation resource:
apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
name: default
namespace: observability
spec:
exporter:
endpoint: http://otel-collector.observability:4317
propagators:
- tracecontext
- baggage
sampler:
type: parentbased_traceidratio
argument: "1.0"
Then annotate a workload:
spec:
template:
metadata:
annotations:
instrumentation.opentelemetry.io/inject-java: "observability/default"
The operator injects an init container carrying the agent and sets the environment variables. The application is not rebuilt and its code is not modified. Java, .NET, Node.js, Python, and Go are supported, though Go requires eBPF-based instrumentation and is meaningfully more constrained than the others.
Set the sampler to 1.0 here and sample later in the Collector. Sampling at the SDK means the decision is made before anyone knows whether the request was interesting.
Why the Collector is not optional
You can export straight from the SDK to Jaeger. You will regret it.
The Collector gives you a place to change sampling, add attributes, redact fields, and switch backends without redeploying a single application. Without it, every one of those is a config change across every service.
A working config:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 25
k8sattributes:
extract:
metadata:
- k8s.namespace.name
- k8s.deployment.name
- k8s.pod.name
- k8s.node.name
batch:
timeout: 5s
send_batch_size: 1024
exporters:
otlp/jaeger:
endpoint: jaeger-collector.observability:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp/jaeger]
Order matters in that processor list. memory_limiter goes first so it can shed load before anything else allocates. batch goes last so it batches the final form of the data. Getting this backwards is a common cause of Collector OOMs under load.
k8sattributes is the one that turns traces from useful into navigable. Every span gets tagged with its namespace, deployment, and node, so you can ask “show me slow spans from the checkout deployment in prod” instead of scrolling.
Tail sampling: the thing that makes this affordable
Head sampling (deciding at the start of a request) is cheap and keeps the wrong traces. It cannot know the request will fail, because it decides before that happens.
Tail sampling buffers complete traces and decides after seeing everything:
processors:
tail_sampling:
decision_wait: 10s
num_traces: 100000
policies:
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
- name: slow
type: latency
latency:
threshold_ms: 1000
- name: baseline
type: probabilistic
probabilistic:
sampling_percentage: 1
Keep every error, every request over a second, and 1% of everything else for baseline comparison. Storage drops by an order of magnitude and you keep the traces you would actually open.
One constraint that catches people: tail sampling requires all spans of a trace to reach the same Collector instance. With multiple replicas you need a two-tier setup. A first layer that routes by trace ID using the loadbalancing exporter, and a second layer that does the sampling. Skip this and you will silently sample fragments of traces, which is worse than no sampling because the gaps look like missing instrumentation.
Deploying Jaeger
For anything beyond a demo, Jaeger needs real storage: Elasticsearch, OpenSearch, or Cassandra. The all-in-one image keeps traces in memory and loses them on restart; it is for local development only.
apiVersion: jaegertracing.io/v1
kind: Jaeger
metadata:
name: jaeger
namespace: observability
spec:
strategy: production
storage:
type: elasticsearch
options:
es:
server-urls: https://elasticsearch.observability:9200
index-prefix: jaeger
esIndexCleaner:
enabled: true
numberOfDays: 14
schedule: "0 2 * * *"
Turn on the index cleaner from the start. Trace data grows fast, and nobody has ever needed a trace from four months ago.
Context propagation is where it breaks
Everything above works and you still get disconnected single-span traces. The cause is almost always propagation.
For a trace to span services, the traceparent header must survive every hop. It breaks at:
- Message queues. HTTP propagation is automatic; queues are not. You must inject context into message headers on publish and extract on consume, by hand.
- Custom HTTP clients. Auto-instrumentation patches known libraries. A hand-rolled client, or one wrapped oddly, is not patched.
- Async work. Spawning a goroutine or a thread pool task without passing context loses the parent.
- Gateways and proxies. Most preserve unknown headers, but a strict allow-list will strip
traceparent. Check this early. It is invisible and produces exactly the symptom above.
Standardise on W3C Trace Context (tracecontext) unless you have legacy B3 propagation to support, in which case configure both during migration.
Add spans where the questions are
Auto-instrumentation gives you HTTP handlers and database calls. That is a good skeleton and it is not enough. It shows time inside POST /checkout without showing which part.
Add manual spans around the parts you would want broken out at 3am:
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("fraud_check") as span:
span.set_attribute("payment.provider", provider)
span.set_attribute("order.value_cents", value)
result = fraud_service.check(order)
span.set_attribute("fraud.decision", result.decision)
Attributes are what make traces searchable. payment.provider as an attribute lets you ask whether one provider is slower than another: a question you cannot answer from span names alone.
Keep cardinality in mind: attributes on spans are far cheaper than labels on metrics, so a user ID is acceptable here in a way it never is on a Prometheus counter.
Connecting the three signals
The real payoff is moving between signals. Put the trace ID in your logs:
span = trace.get_current_span()
trace_id = format(span.get_span_context().trace_id, "032x")
logger.info("payment failed", extra={"trace_id": trace_id})
Now an error log links to the trace showing exactly what that request did. With exemplars, Prometheus can attach trace IDs to latency histogram buckets, so you can click a p99 spike on a Grafana panel and land on a trace from that bucket.
That path (dashboard spike to specific slow request in two clicks) is what tracing is for. Everything else is setup.