Distributed Tracing With Jaeger: Pinpointing Latency Bottlenecks In Microservices
Distributed Tracing with Jaeger: Pinpointing Latency Bottlenecks in Microservices
Microservices give you deployment flexibility and team autonomy, but they'll absolutely destroy your ability to debug latency issues if you don't have the right observability tooling in place. When a request touches eight services before returning a response, "the API is slow" tells you nothing. Distributed tracing tells you everything.
Jaeger is my go-to open-source tracing solution. It's battle-tested, integrates cleanly with OpenTelemetry, and the UI is genuinely useful for flame-graph style trace analysis. Let's walk through setting it up, instrumenting your services, and — more importantly — actually using it to find where your latency is hiding.
Why Distributed Tracing, Not Just Logs
Before we jump into code, let me make the case clearly. You already have logs. You probably have metrics. Why do you need tracing?
Logs are isolated per service. Metrics show you that something is slow. Traces show you where and why. A trace follows a single request through every service it touches, recording timing data at each hop. When your P99 latency spikes, traces let you drill into a specific slow request and see that order-service waited 800ms for a database query while everything else completed in under 50ms.
That's the difference between hours of debugging and minutes.
Standing Up Jaeger Locally
For development, Docker Compose is the fastest path to a working Jaeger instance:
version: '3.8'
services:
jaeger:
image: jaegertracing/all-in-one:1.52
ports:
- "16686:16686" # Jaeger UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "6831:6831/udp" # Jaeger Thrift compact
environment:
- COLLECTOR_OTLP_ENABLED=true
networks:
- observability
networks:
observability:
driver: bridge
The all-in-one image bundles the collector, query service, and UI — perfect for development and testing. In production you'll want these as separate deployments with a proper backend store (Elasticsearch or Cassandra) instead of the default in-memory storage.
Hit http://localhost:16686 after startup and you'll have the Jaeger UI ready to receive traces.
Instrumenting a Go Service with OpenTelemetry
I strongly recommend instrumenting via OpenTelemetry rather than Jaeger's native client libraries. OTel is vendor-neutral — if you ever swap Jaeger for Tempo or Honeycomb, your instrumentation code doesn't change. Only the exporter configuration does.
Here's a complete tracer setup for a Go service:
package telemetry
import (
"context"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
"google.golang.org/grpc"
)
func InitTracer(serviceName, jaegerEndpoint string) (*sdktrace.TracerProvider, error) {
exporter, err := otlptracegrpc.New(
context.Background(),
otlptracegrpc.WithEndpoint(jaegerEndpoint),
otlptracegrpc.WithInsecure(), // Use WithTLSClientConfig in production
otlptracegrpc.WithDialOption(grpc.WithBlock()),
)
if err != nil {
return nil, fmt.Errorf("failed to create exporter: %w", err)
}
res, err := resource.New(
context.Background(),
resource.WithAttributes(
semconv.ServiceName(serviceName),
semconv.ServiceVersion("1.0.0"),
semconv.DeploymentEnvironment("production"),
),
)
if err != nil {
return nil, fmt.Errorf("failed to create resource: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.1)), // 10% sampling in prod
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(
propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
),
)
return tp, nil
}
Notice the TraceIDRatioBased(0.1) sampler — in production at scale, sampling 100% of requests is expensive. Ten percent gives you statistically significant data without the storage overhead. For debugging a specific issue, you can bump this to 1.0 temporarily.
Creating Meaningful Spans
Initializing the tracer is the easy part. The value comes from creating spans that tell a coherent story. Here's how to instrument an HTTP handler with meaningful child spans:
package handlers
import (
"net/http"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
var tracer = otel.Tracer("order-service")
func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "CreateOrder")
defer span.End()
// Add contextual attributes — these are searchable in Jaeger UI
span.SetAttributes(
attribute.String("user.id", r.Header.Get("X-User-ID")),
attribute.String("http.method", r.Method),
attribute.String("http.route", "/orders"),
)
orderID, err := processOrder(ctx, r)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
http.Error(w, "failed to create order", http.StatusInternalServerError)
return
}
span.SetAttributes(attribute.String("order.id", orderID))
w.WriteHeader(http.StatusCreated)
}
func processOrder(ctx context.Context, r *http.Request) (string, error) {
// Each logical operation gets its own span
ctx, dbSpan := tracer.Start(ctx, "ValidateInventory")
if err := checkInventory(ctx); err != nil {
dbSpan.RecordError(err)
dbSpan.End()
return "", err
}
dbSpan.End()
ctx, paymentSpan := tracer.Start(ctx, "ProcessPayment")
orderID, err := callPaymentService(ctx)
if err != nil {
paymentSpan.RecordError(err)
paymentSpan.End()
return "", err
}
paymentSpan.End()
return orderID, nil
}
The key discipline here: span names should be operation names, not implementation details. ValidateInventory is good. checkInventory_v2_new is noise. You want trace UIs that read like a business process, not a call stack.
Propagating Context Across Service Boundaries
Here's where most teams get tripped up. Spans only form a connected trace if context is correctly propagated between services. For HTTP calls between services, you need to inject trace headers into outgoing requests and extract them on the receiving end.
Outgoing HTTP client with context propagation:
package clients
import (
"context"
"net/http"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
// Wrap your HTTP client with OTel instrumentation
func NewInstrumentedClient() *http.Client {
return &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
}
// Usage — context carries the trace, OTel handles header injection automatically
func CallInventoryService(ctx context.Context, productID string) error {
client := NewInstrumentedClient()
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"http://inventory-service/products/"+productID,
nil,
)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
On the server side, use otelhttp.NewHandler to automatically extract incoming trace context:
mux := http.NewServeMux()
mux.HandleFunc("/products/", InventoryHandler)
// This middleware extracts trace context from incoming requests
handler := otelhttp.NewHandler(mux, "inventory-service")
http.ListenAndServe(":8080", handler)
The otelhttp instrumentation library handles traceparent header injection and extraction automatically. Don't try to do this manually — you'll get it wrong and end up with disconnected traces.
Reading Traces and Identifying Bottlenecks
Now the fun part. Once traces are flowing into Jaeger, here's how to systematically hunt latency:
1. Use the search to find slow traces first. In the Jaeger UI, filter by service and set a minimum duration threshold. If your SLA is 500ms, start by looking at traces over 1 second. Don't start with average traces — outliers reveal the worst problems.
2. Look for sequential calls that could be parallel. If you see this pattern in a trace:
[OrderService - CreateOrder: 900ms]
├─ [InventoryService - Check: 300ms]
├─ [UserService - GetProfile: 250ms]
└─ [PricingService - Calculate: 350ms]
Those three child calls are executing sequentially when they don't need to be. Parallelizing them drops your total latency to roughly 350ms (the slowest of the three). This is one of the highest-ROI findings distributed tracing surfaces.
3. Watch for "wide" database spans. A database query span taking 500ms is almost always an N+1 query or a missing index. The trace tells you exactly which service and which operation — you don't have to guess.
4. Identify retry storms. If you see the same span repeating multiple times with short gaps, you're looking at a retry loop. Traces make this blindingly obvious in a way that metrics dashboards often obscure.
Production Considerations
A few things I've learned the hard way:
Use a proper backend store. Jaeger's in-memory store loses data on restart. For production, deploy with Elasticsearch. The Jaeger Helm chart makes this straightforward:
helm install jaeger jaegertracing/jaeger \
--set storage.type=elasticsearch \
--set storage.elasticsearch.host=elasticsearch-master \
--set collector.replicaCount=2
Set retention policies. Traces are high-cardinality data. 7-14 days of retention is usually sufficient — you're not doing historical trend analysis with traces, you're debugging active or recent incidents.
Sample intelligently. Consider using tail-based sampling for production workloads. With head-based sampling (what we configured above), you sample randomly at trace ingestion. Tail-based sampling evaluates the completed trace and keeps it only if something interesting happened (errors, high latency). The Jaeger Collector supports tail-based sampling configuration.
Add baggage for user context. OpenTelemetry baggage lets you propagate key-value pairs through the entire trace without manually adding them to every span. User IDs, tenant IDs, and feature flag states belong in baggage — it makes filtering traces in production dramatically easier.
The Payoff
I've used distributed tracing to find a payment service that was making a synchronous database call for every line item in an order (classic N+1, surfaced in 10 minutes with tracing, would have taken days with logs). I've found a service that was waiting for a downstream timeout on every request because someone had set http.DefaultClient with no timeout. I've caught circular dependencies between services that were adding 400ms of latency on every request.
The setup cost is maybe half a day for a mature microservices platform. The return on that investment shows up the first time you're on-call at 2am and you can tell your team "it's the inventory service database query on /orders/checkout" within three minutes of the alert firing.
That's what distributed tracing is for. Jaeger makes it approachable and production-ready without locking you into a vendor. Get it instrumented, get it deployed, and stop guessing where your latency lives.
Was this article helpful?
Senior Cloud Architect
AWS, GCP, and Azure — I've built production workloads on all three. From landing zone design to multi-region failover, I architect cloud infrastructure that scales without surprises. Well-Architected Framework isn't a checklist, it's a mindset.
Related Articles
Prometheus Scrape Target Down: Diagnosing And Fixing "connection Refused" Errors Step By Step
If you've spent any time with Prometheus, you've seen it. That red `DOWN` label in the Targets page, accompanied by the dreaded `connection refused` error....
Building a Complete Prometheus + Grafana Monitoring Stack from Scratch
Build a production Prometheus and Grafana monitoring stack from scratch — service discovery, recording rules, alerting, and dashboards.
PromQL: Cheat Sheet
PromQL cheat sheet with copy-paste query examples for rates, aggregations, histograms, label matching, recording rules, and alerting expressions.
OpenTelemetry Collector: Deploying Your Observability Pipeline the Right Way
Deploy and configure the OpenTelemetry Collector to unify traces, metrics, and logs into a single pipeline — with production-tested patterns.
Prometheus Recording Rules: Fix Your Query Performance Before It Breaks Grafana
Use Prometheus recording rules to pre-compute expensive queries, speed up dashboards, and make SLO calculations reliable at scale.
Prometheus Alerting Rules That Don't Wake You Up for Nothing
Design Prometheus alerting rules that catch real incidents and ignore noise — practical patterns from years of on-call experience.
More in Monitoring
View all →DNS Troubleshooting for DevOps: dig, nslookup, and Common Failures
A practical DNS troubleshooting guide for DevOps engineers — dig commands, nslookup patterns, common production failure modes, and how to diagnose each one.
Elasticsearch Cluster Sizing for Production: Nodes, Shards, and Memory
A practical guide to sizing Elasticsearch clusters for production — covering node roles, shard counts, heap tuning, and capacity planning formulas.
Scalable Log Aggregation with Grafana Loki and Promtail
Deploy Grafana Loki and Promtail for cost-effective, scalable log aggregation — without indexing yourself into bankruptcy.
Designing Grafana Dashboards That SREs Actually Use
Build Grafana dashboards that surface real signals instead of decorating walls — a structured approach rooted in SRE principles.