Command Palette

Search for a command to run...

Hectal
Case 0.2·Seeing Anything at AllSEV3

“The dashboard says checkout takes 400 ms. Support says customers are waiting 4 seconds.”

case name: Averages lie — percentiles and histograms

Service
shoplite-api · /checkout
Impact
1 in 10 checkouts took 3+ seconds for ~2 hours; some users gave up and retried
Detected by
Support tickets: 'the pay button spins forever'
Time to resolve
25 min once the right query was run

Skills you'll use on this case

histograms and bucketshistogram_quantilesum by (le)average vs p50/p99quantile estimation error

00 It starts

Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.

terminal
$ curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"slow-checkout"}'
── lab output ──
{"active":["slow-checkout"]}
From now on, 10% of checkouts run a 3-second database query. Wait 5 minutes before starting.

01 The investigation

  1. 14:10

    REPORT

    Three support tickets in an hour: 'payment button spins for ages, then works'

    Intermittent and eventually successful, the hardest kind of report to act on.

  2. 14:12

    QUERY

    Check average latency — the number on the existing dashboard

    A histogram's _sum is the total seconds spent; _count is the number of requests. Dividing their rates gives the MEAN latency.

    PromQL· Prometheus
    sum(rate(http_request_duration_seconds_sum{job="shoplite", route="/checkout"}[5m]))
    /
    sum(rate(http_request_duration_seconds_count{job="shoplite", route="/checkout"}[5m]))
    result
    {}  0.412
  3. 14:14

    DEAD END

    '412 ms is a bit high but fine — probably the customers' mobile networks'

    The average blends 90 fast requests (~110 ms) with 10 very slow ones (~3.1 s) into one number that describes NOBODY's actual experience. No customer waited 412 ms.

  4. 14:31

    QUERY

    Look at the distribution instead: median and 99th percentile

    histogram_quantile estimates a percentile from bucket counts. The buckets (le = 'less than or equal') must be summed across instances first and KEPT as a label, hence sum by (le).

    PromQL· Prometheus
    histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket{job="shoplite", route="/checkout"}[5m])))
    histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{job="shoplite", route="/checkout"}[5m])))
    result
    p50  {}  0.108
    p99  {}  4.74
    /checkout latency — mean vs p99 (seconds)meanp99
    0.00s2.74s5.47sSLO 1s12:3014:30
  5. 14:33

    FINDING

    Median 108 ms, p99 4.7 s: this is a slow TAIL, not a slow service

    Half the requests are fast. A small fraction is dramatically slow. That changes the search completely: look for something that happens occasionally (a specific query, a lock, a cold cache), not for a service that's slow overall.

  6. 14:35

    QUERY

    How many checkouts are over 1 second?

    Buckets are cumulative, so the le="1" bucket counts requests that took at most 1s. One minus its share of the total is the fraction slower than 1s. This number is exact, not estimated.

    PromQL· Prometheus
    1 - (
      sum(rate(http_request_duration_seconds_bucket{job="shoplite", route="/checkout", le="1"}[5m]))
      /
      sum(rate(http_request_duration_seconds_count{job="shoplite", route="/checkout"}[5m]))
    )
    result
    {}  0.101
  7. 14:52

    RESOLVED

    A slow report query sharing the table is moved to a read replica

    In our lab the slow query is pg_sleep(3), a stand-in for any occasionally slow statement. Case 0.4 shows how a trace pinpoints exactly which call in the request is slow.

Root cause

10% of checkout requests hit a 3-second database query. The team's only latency number was the MEAN, which diluted the problem to '412 ms, a bit high'. The p99 was 4.7 s and 10% of users waited over a second, but nobody could see that because nobody looked at the distribution.

02 The concepts behind it

Why averages hide the problem

A mean is dominated by the common case and says nothing about the shape of the distribution. 90 requests at 110 ms and 10 at 3.1 s average to 410 ms, a value no real request had. Worse, the mean can stay flat while the tail gets dramatically worse, as long as the fast majority gets slightly faster.

Users experience individual requests, and a user who makes 20 requests per session almost certainly hits the p95. Measure latency as PERCENTILES: p50 for 'typical', p95/p99 for 'bad but common enough to matter'.

How Prometheus histograms work

A histogram is a set of cumulative counters: _bucket{le="0.1"} counts requests that took ≤ 0.1 s, le="0.25" counts those ≤ 0.25 s (including the previous ones), and so on up to le="+Inf", which equals _count. Plus _sum, the total time. Each is a separate time series per label combination, which is why bucket count affects storage cost.

Because buckets are just counters, they can be SUMMED across instances and routes before computing a percentile. That's the big advantage over client-side 'summaries', whose pre-computed quantiles can't be aggregated.

Percentiles from buckets are estimates

histogram_quantile finds which bucket the requested rank falls into, then assumes observations are spread evenly inside it. Our slow requests take ~3.1 s, and all of them land in the (2.5, 5] bucket. The p99 rank is 90% of the way into that bucket's population, so the estimate is 2.5 + 0.9 × 2.5 ≈ 4.75 s, not the true 3.1 s.

The lesson: put bucket BOUNDARIES where decisions happen, such as your SLO thresholds (0.3 s, 1 s), so questions like 'what fraction is under 1 s?' are exact. Newer Prometheus versions also support NATIVE histograms, with automatic high-resolution buckets that remove most of this error; check your version's documentation for how to enable them.

rate() first, then sum by (le), then quantile

The order matters. rate() must be applied to the raw counters (it handles resets per series). sum by (le) then aggregates across instances and other labels while keeping the bucket boundary. histogram_quantile needs le to be present. To get a percentile per route, keep that label too: sum by (le, route).

03 The fix

  1. 01Turn the slow query off

    In real life this is where you'd find and fix the slow statement. Case 0.4 shows how to find exactly which call is slow using a trace.

    terminal
    $ curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"slow-checkout","enabled":false}'
    ── expected output ──
    {"active":[]}

04 Make sure it never surprises you again

  1. 01Put a bucket boundary at your SLO threshold

    ShopLite's target is 'checkout under 1 s'. 1 is already a boundary, which is why the 'fraction over 1 s' query was exact. Add boundaries where your decisions live, such as 0.3 for the product page, and remove ones you never use. Each bucket is a separate time series per label combination, so more buckets means more storage.

    obs-lab/shoplite/server.jsadd to filejs
      buckets: [0.025, 0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1, 2, 3, 5, 10],
  2. 02Chart percentiles per route, never the mean alone

    Use this query in the latency panel of the ShopLite dashboard: p99 for every route, estimated from summed buckets.

    PromQL· Prometheus
    histogram_quantile(0.99, sum by (le, route) (rate(http_request_duration_seconds_bucket{job="shoplite"}[5m])))
    result
    {route="/checkout"}  0.231
    {route="/products"}  0.047

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.

01

Write a query for the p95 latency of the payments service's /charge route.

02

Someone writes histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])) and gets one result per instance, method, route, and status. Why, and how do you fix it?

03

With slow-checkout on, what fraction of ALL ShopLite requests (not just checkout) take longer than 1 s?

06 Interview questions from this case

01

Why should latency be tracked as percentiles rather than averages?

02

Histogram vs summary in Prometheus: which do you choose?

03

What does histogram_quantile(0.99, sum by (le) (rate(x_bucket[5m]))) actually compute?

0/4 · 0%