“Checkout p99 is 2.8 seconds. The DBA swears Postgres is idle.”
case name: Slow, but not the database — distributed traces
- Service
- shoplite-api → payments
- Impact
- Every checkout took ~2.7 s for 35 minutes; conversion dropped
- Detected by
- p99 latency panel from Case 0.2
- Time to resolve
- 35 min
Skills you'll use on this case
traceparent)TraceQLlogs ↔ tracestimeouts00 It starts
CheckoutLatencyP99High
p99 latency for /checkout is 2.81s (threshold 1s)
Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.
01 The investigation
- 11:40
ALERT
p99 for /checkout crosses 1 s and stays there
Unlike Case 0.2, this isn't a tail: p50 is slow too. EVERY checkout is slow.
PromQL· Prometheushistogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket{job="shoplite", route="/checkout"}[5m])))result{} 2.71 - 11:44
DEAD END
Hypothesis: the database. Postgres CPU 4%, no slow queries, no locks
Everyone's first suspect, and a classic time sink. Metrics about ShopLite can only say 'ShopLite's requests are slow'. They can't say which PART of each request is slow.
- 11:51
QUERY
Search Tempo for slow checkout traces
TraceQL selects spans by attributes inside
{ }.resource.service.nameis the service,span.http.routethe route (set by the Express instrumentation), anddurationthe span's own duration.TraceQL· Tempo{ resource.service.name = "shoplite" && span.http.route = "/checkout" && duration > 1s }resultTrace ID Start Root service Root span Duration 4bf92f3577b34da6a3ce929d0e0e4736 11:51:07 shoplite POST /checkout 2.71s 9a01c3de66f14b1b8c02e5e8f2a7d913 11:51:07 shoplite POST /checkout 2.69s ... - 11:52
FINDING
The waterfall: 2.68 s of the 2.71 s is inside the payments service
Both Postgres queries take 2–4 ms. The outgoing
POSTto payments takes 2,689 ms, and nearly all of it is thePOST /chargespan INSIDE payments. The trace crossed a service boundary because ShopLite'sfetchsent atraceparentheader, which the payments instrumentation continued.Trace 4bf92f35… — POST /checkout2712 ms totalshoplitepayments POST /checkoutshoplite2712 msmiddleware - jsonParsershoplite1 mspg.query:SELECT shopliteshoplite2 msPOST (fetch → payments)shoplite2689 msPOST /chargepayments2681 mspg.query:INSERT shopliteshoplite4 ms - 11:55
QUERY
Jump from the trace to its logs
The pino instrumentation injected
trace_idinto every log line written during the request, and the Grafana data sources are linked, so from any span you can open exactly that request's logs, in both services.LogQL· Loki{container=~"shoplite|payments"} | json | trace_id = "4bf92f3577b34da6a3ce929d0e0e4736"result11:51:07.002 shoplite checkout started card_last4=1111 productId=2 11:51:09.690 payments request completed responseTime=2681 statusCode=200 11:51:09.712 shoplite request completed responseTime=2712 statusCode=201 - 12:15
RESOLVED
The payment provider's degraded region is failed over; /charge back to ~70 ms
The follow-up for ShopLite itself: a 5-second timeout on a call that normally takes 70 ms is far too generous. Users waited the full slowdown instead of getting a fast, retryable error.
Root cause
The payments service (standing in for an external payment provider) was adding ~2.5 s to every /charge. ShopLite's metrics could only show that its own requests were slow; only a distributed trace, with context propagated across the HTTP call, could show that the time was spent in a downstream dependency.
02 The concepts behind it
Traces and spans
A SPAN is one timed operation, such as handling an HTTP request, running a query, or calling another service, with a name, start time, duration, status, and attributes. A TRACE is the tree of spans for one request, linked by a shared TRACE ID and parent/child span IDs. Rendered as a waterfall, it shows where time goes and which calls happen in sequence versus in parallel.
Context propagation
For a trace to cross services, the caller must pass its trace context along. With W3C Trace Context that's the traceparent header: 00-<32-hex trace id>-<16-hex parent span id>-<flags>. OpenTelemetry's HTTP client instrumentation adds it to outgoing requests, and the server instrumentation reads it and creates child spans in the same trace. If ANY hop drops the header (a proxy, an uninstrumented client, a message queue), the trace breaks into disconnected pieces. Chapter 3 has a case about exactly that.
Auto vs manual instrumentation
AUTO-instrumentation patches common libraries (HTTP, Express, pg, fetch, pino) at startup, giving you useful traces with zero code changes. That's what tracing.js does, and why it must load first. MANUAL instrumentation adds spans and attributes for your own business logic (tracer.startActiveSpan("reserve-stock", ...)), which is essential when the slow part is inside your code rather than a library call.
Correlating the three signals
The workflow this chapter builds: a METRIC alert says something is wrong; an exemplar or a TraceQL search finds example TRACES that show where; the trace ID leads to the LOGS with the details. The glue is shared identifiers (trace_id in logs) and consistent labels (service, route) across all three.
03 The fix
01Fail fast: a timeout that matches reality
/chargenormally takes under 150 ms. A 5-second timeout means a degraded dependency makes every user wait 5 seconds. Set it to about 3–5× the normal p99 so slow calls fail quickly and clearly, return an error the client can retry, and stop tying up ShopLite's connections. The error logs then say what happened, and the error-ratio alert from Case 0.3 fires.obs-lab/shoplite/server.jsadd to filejs signal: AbortSignal.timeout(1000), // payments p99 is ~120 ms; fail fast instead of making users wait02Switch the incident off
terminal$ curl -s -X POST localhost:8081/admin/chaos -H 'content-type: application/json' -d '{"scenario":"slow-payments","enabled":false}'── expected output ──{"active":[]}
04 Make sure it never surprises you again
01Alert on the DEPENDENCY's latency, not only your own
Payments exposes the same histogram. A rule on its p99 fires on the real culprit, often before ShopLite's own latency alert, and its name tells on-call where to look first.
obs-lab/prometheus/alerts.ymladd to fileyaml - alert: PaymentsLatencyP99High expr: | histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{job="payments", route="/charge"}[5m]))) > 0.5 for: 5m labels: severity: warning service: payments annotations: summary: "payments /charge p99 is {{ $value | humanizeDuration }}"
05 Your turn: write the query
Run each one against your lab before revealing the answer. Share your own version in the comments; there's usually more than one correct query.
With slow-payments ON, write a TraceQL query that finds payments spans slower than 2 seconds.
Look at the raw headers payments receives. Temporarily add req.log.info({ tp: req.headers.traceparent }, "hdr") to /charge, rebuild, and find the log line. Which part of the value matches the trace ID?
Why did the trace show POST (fetch → payments) taking slightly LONGER than the POST /charge span inside payments?
06 Interview questions from this case
What's distributed tracing and when do you need it?
How does trace context get from one service to another?
How do you connect logs to traces?