histogram_quantile
"p99 checkout latency" cannot be stored as a number and averaged later — quantiles do not compose. Two instances each reporting a p99 give you two p99s and no correct way to combine them into the fleet's. Prometheus's classic answer is to store a histogram: a family of cumulative counters, one per bucket boundary, each tagged with the le (less-or-equal) label.
histogram_quantile() reconstructs an estimated quantile from those bucket rates at query time. The estimate is exactly as accurate as the bucket layout and not one millisecond better — a sentence this topic earns twice, once at query time here and once when Chapter 5 chooses the buckets.
Buckets Are Counters
harborline_checkout_duration_seconds_bucket{le="0.5"} counts every observation that took 500 ms or less since process start. Buckets are cumulative: the le="1" bucket contains everything in le="0.5" plus the observations between them, and the le="+Inf" bucket equals _count, the total. Because each bucket is an ordinary counter, everything from the rate() topic applies unchanged — rate the buckets before doing anything else with them.
The bookings service does not emit this metric yet; Chapter 5 instruments it. It previews here because histograms are where PromQL earns its keep. The Chapter 3 blackbox probe stores latency as a gauge, and quantile_over_time(0.99, probe_duration_seconds[1h]) can rank one stream's samples — but that trick works on a single series at a time and does not scale to a population of requests spread across instances, which is exactly what the checkout SLO is about.
The Canonical Shape
One line produces every latency percentile in the rest of the book, and it is worth reading inside-out once.
# fleet-wide p99 checkout latency, classic histogram histogram_quantile(0.99, sum by (le) (rate(harborline_checkout_duration_seconds_bucket[5m])))
Rate first, because buckets are counters and reset detection works per raw series. Sum second, to merge the instances into one bucket family — the by (le) is load-bearing, because summing away the le label destroys the structure the quantile is computed from. Quantile last, over the merged bucket rates. Every histogram query you will write is a variation of this one line.
Interpolation, Honestly
The function finds the bucket the target rank falls in and linearly interpolates between that bucket's boundaries. With boundaries at 250 ms and 500 ms, a reported p99 of 487 ms means "somewhere between 250 and 500, probably nearer 500" — the three digits are interpolated fiction. The honest resolution of a classic-histogram quantile is the width of the bucket it lands in, which is why bucket layout is a design decision, not a default to inherit.
Edge Behavior Worth Memorizing
If the quantile lands in the +Inf bucket, the result is pinned to the highest finite boundary: a p99 flatlined at exactly 10 means your latencies escaped the bucket layout, not that they went stable. If it lands in the lowest bucket, the lower bound is assumed to be 0. Fewer than two buckets, or a bucket family missing +Inf, returns NaN.
Why Buckets Beat Recorded Percentiles
Bucket counters sum cleanly across instances, so one query yields the true fleet-wide p99 whether bookings runs on two hosts or twenty. A system that pre-computes per-instance percentiles — Prometheus's summary type, or a pipeline that stores "p99" as a plain number — can never aggregate them correctly afterwards, because the underlying distribution is already gone. That composability is the entire reason to pay the histogram's price of 10-plus series per label combination.
Native Histograms, Briefly
The successor format stores exponentially-scaled buckets inside a single series, giving far finer resolution at a fraction of the series cost, and histogram_quantile() works on them with no le label involved. Native histograms went stable in Prometheus 3.8, but a target still only exposes them when you turn on scrape_native_histograms in the scrape config, so the classic explicit-bucket form is still what most fleets run in production today; Chapter 5 covers instrumenting both.
sum(rate(harborline_checkout_duration_seconds_bucket[5m]))withoutby (le)— the bucket structure is summed away andhistogram_quantilereturns NaN across the board. This is the single most common histogram query failure, and the error is silent emptiness, not a message.- Averaging per-instance quantiles —
avg(histogram_quantile(0.99, ... by (le, instance)))weights an instance serving 3 req/s equally with one serving 300, and the resulting "fleet p99" can sit below latencies that half your traffic actually experienced. Aggregate the buckets, then take one quantile. - Reading a flat p99 pinned at the top finite bucket as stability — when Saturday checkout latencies blow past the largest boundary, the graph draws a perfectly straight line at that boundary. The calm line is the alarm.
- Keeping the client library's default buckets for an SLO metric — the defaults top out at 10 s with nothing between 1 s and 2.5 s; with a 500 ms SLO threshold you want boundaries clustered around 0.5, or the one number you care about is the one you cannot resolve.
- Pointing
histogram_quantileat_sumor_count— neither carries anlelabel, so the result is NaN. Those two series exist to compute the average,rate(..._sum[5m]) / rate(..._count[5m])— a different and complementary question.
- Write every classic-histogram quantile as
histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m])))— same shape for any quantile and window — and treat any deviation from it as a code smell in review. - Place bucket boundaries on the decision thresholds — the 500 ms checkout SLO gets boundaries at 0.25, 0.5, and 0.75, so the SLO line falls on a boundary, where the estimate is exact.
- Alert on the bucket ratio directly when the threshold matches a boundary —
rate(..._bucket{le="0.5"}[5m]) / rate(..._count[5m])is the exact fraction of requests under 500 ms, cheaper and truer than thresholding an interpolated quantile. - Keep explicit histograms to roughly 10–15 buckets — every boundary is a full extra series per label combination, and cardinality, the book's first cost, is paid per bucket.
Knowledge Check
Why is by (le) mandatory in the canonical histogram query?
- Reset detection needs the le label to pair buckets across restarts
- Summing without le destroys the bucket structure the quantile is interpolated from
- PromQL rejects sum() over bucket series unless an explicit grouping clause is present at query time
- Grouping by le makes the query cheap enough to run at dashboard refresh rates
A p99 panel draws a perfectly straight line at exactly 10 for two hours. What happened?
- Latency genuinely stabilized at 10 seconds
- Scrapes failed and the panel is repeating the last good sample
- The quantile landed in the +Inf bucket and is pinned to the highest finite boundary
- histogram_quantile rounds results above 5 seconds up to the nearest whole-second integer value
Why can bucket counters be aggregated across instances when computed percentiles cannot?
- Summing bucket counts preserves the combined distribution; a stored percentile has already thrown it away
- Buckets are stored in a special mergeable format that percentiles lack, so only histograms survive re-aggregation intact
- They cannot — averaging per-instance p99s gives the same answer more cheaply
- The summary type proves percentiles aggregate fine when computed client-side
With buckets at 250 ms and 500 ms, what does a reported p99 of 487 ms actually tell you?
- That 99% of requests completed in 487 ms or less, to millisecond accuracy
- The p99 is somewhere in the 250–500 ms bucket, likely near the top of it
- The true p99 could be anywhere from 0 to 10 seconds
- The query is malformed and 487 is a garbage value
What are the _sum and _count series of a histogram for?
- Feeding histogram_quantile as a fallback when the bucket series are unavailable
- Detecting counter resets in the bucket family
- Computing the average — total observed time over total observations
- Serving as backup copies of the +Inf bucket
You got correct