PromQL Deep Dive
Observability
Chapter 4 · PromQL Deep Dive
obs1-2 left a promise on the table: a raw counter value is rarely useful by itself. This chapter delivers on it — PromQL, the query language that turns Ch.2's raw time series into the numbers a dashboard or an alert actually cares about.
Instant Vectors vs. Range Vectors
http_requests_total alone is an instant vector — the current value of every matching time series, at one point in time. http_requests_total[5m] is a range vector — every value each matching series took over the trailing five minutes. Range vectors can't be graphed directly; they exist specifically as input to functions like rate() that need a window of history to compute something meaningful.
rate() and irate() — Turning a Counter Into Something Useful
rate() computes the per-second average rate of increase over the given window — exactly the transformation Ch.2 promised a raw counter needed. It also automatically detects and compensates for counter resets (a process restart dropping the counter back to zero), a genuinely important detail: without that handling, a restart would otherwise show up as a nonsensical negative rate. irate() computes an instantaneous rate using only the last two data points in the range, more responsive to sudden spikes but noisier — rate() is the safer default for dashboards and alerting; irate() suits fast-moving, high-resolution graphs where responsiveness matters more than smoothness.
Aggregation Operators — Collapsing Across Labels
sum(), avg(), min(), max(), and count() combine values across every matching series. The by (...) clause keeps specific label dimensions in the result, collapsing everything else; without (...) does the reverse — drop these labels, keep the rest. Choosing the right dimension to keep is exactly what turns obs1-2's own per-instance, per-status, per-method time series into the one useful number a dashboard panel actually wants.
histogram_quantile() — Delivering on Chapter 2's Own Promise
obs1-2 named histograms as aggregatable specifically because their bucket counts can be summed across instances. This is that promise made concrete: rate() turns each bucket's cumulative count into a per-second rate, sum(...) by (le) combines those rates across every instance while explicitly preserving the bucket-boundary label, and histogram_quantile() then computes an approximate p99 from the combined buckets.
Common Query Patterns
=~ matches a label against a regular expression — "5.." catches every 5xx status code in one pattern. increase() is rate()'s own close relative: instead of a per-second rate, it reports the total increase across the whole window, correctly handling counter resets the same way rate() does — the right choice for "how many of X happened," rather than "how fast is X happening."
| Function | Computes | Best for |
|---|---|---|
| rate() | Average per-second rate over the window | Dashboards, alerting — smoother, safer default |
| irate() | Instantaneous rate from the last two points | Fast-moving graphs where responsiveness matters most |
| increase() | Total increase over the window | "How many happened" questions, not "how fast" |
rate(), irate(), or increase() first — turning "a running total" into "how fast is this changing" or "how many happened in this window" — is what actually makes a counter worth looking at.
sum(rate(...)) by (status) instead of by (le) on a histogram metric doesn't produce an error — it produces a query that runs, returns a number, and is meaningless, since histogram_quantile() has no bucket boundaries left to compute a quantile from once le is aggregated away. This is a genuinely common, easy-to-miss mistake precisely because nothing fails loudly when it happens.
Hands-On Exercises
Write a PromQL query computing the per-second rate of 4xx responses over the last 10 minutes for the metric http_requests_total, and explain why rate() rather than the raw counter is the right choice for a dashboard panel.
📄 View solutionWrite a query computing the p95 latency across all instances for the metric http_request_duration_seconds_bucket, and explain why the by (le) clause is required for the result to be meaningful.
📄 View solutionA service restarts mid-window, causing its request counter to drop back to zero. Explain what rate() does in this situation, and why a naive "current value minus value five minutes ago" calculation would produce a wrong (negative) result instead.
📄 View solutionChapter 4 Quick Reference
- Instant vector — one value per series right now; range vector — a window of values, e.g. [5m]
- rate() — smooth per-second average, counter-reset-aware, the safe default for dashboards/alerts
- irate() — instantaneous, from the last two points; noisier, more responsive
- increase() — total change over the window, for "how many" rather than "how fast"
- sum()/avg()/min()/max()/count() ... by (...) — aggregate across labels, keeping the dimensions listed in by
- histogram_quantile(q, sum(rate(..._bucket[5m])) by (le)) — the le label must survive aggregation, or the result is silently meaningless
- Never graph a raw counter directly — always wrap it in rate/irate/increase first