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(http_requests_total{status="500"}[5m])

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

# Total request rate, broken down by status code, collapsed across every instance/method/handler sum(rate(http_requests_total[5m])) by (status) # One single fleet-wide number, collapsing every label including status sum(rate(http_requests_total[5m]))

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

histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))

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

# Error rate as a percentage of total requests sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) * 100 # How many errors happened in the last hour increase(errors_total[1h])

=~ 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."

FunctionComputesBest for
rate()Average per-second rate over the windowDashboards, alerting — smoother, safer default
irate()Instantaneous rate from the last two pointsFast-moving graphs where responsiveness matters most
increase()Total increase over the window"How many happened" questions, not "how fast"
Never graph a raw counter directly
A raw counter, plotted as-is, is nearly always a misleading, ever-climbing line that tells you almost nothing useful on its own. Wrapping it in 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.
Aggregating away the le label breaks histogram_quantile() silently
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

Exercise 1

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 solution
Exercise 2

Write 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 solution
Exercise 3

A 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 solution

Chapter 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