Design a Live Metrics Dashboard
Cardinality is the ingest budget and roll-ups are the query budget: how a dashboard stays fast as the window widens.
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))A live metrics dashboard is a read-heavy query engine over an append-only, time-ordered write path. It fails in two distinct places: the ingest path, which dies of cardinality, and the query path, which dies when somebody selects "last 90 days".
Cardinality is the budget
A series is one metric name plus one combination of label values, and the store's cost tracks the number of series far more than the number of points. Labels multiply: 20 routes × 5 status classes × 40 pods is 4,000 series from a single counter. Put an unbounded value in a label — user id, raw URL with ids in it, request id — and it is ingest that falls over, not the chart.
Pre-aggregate, then roll up
Aggregate before storage wherever you can: counters kept in the process and flushed once per interval, and recording rules that write an expensive query's result as its own series on every evaluation. Then downsample by age. Thanos is the explicit version of this: raw blocks are downsampled to 5-minute resolution once they are older than 40 hours, and 5-minute blocks to 1-hour after 10 days, with retention configured per resolution (--retention.resolution-raw, --retention.resolution-5m, --retention.resolution-1h).
Queries that stay fast
Choose the resolution from the window, not from the metric: a 1,000-pixel chart of 90 days cannot use 10-second points, so the query layer picks the coarsest roll-up that still fills the chart. Quantiles must come from a distribution — histogram buckets or a mergeable sketch — because an average of per-pod p99s is not a p99 of anything.
Example
// Dashboard query layer. The window decides the roll-up; the metric does not.
// Coarsest last. 'retention' is how far back that resolution still exists.
const RESOLUTIONS = [
{ stepSec: 15, retentionSec: 36 * 3600, suffix: '' }, // raw scrapes
{ stepSec: 300, retentionSec: 90 * 24 * 3600, suffix: ':5m' }, // downsampled
{ stepSec: 3600, retentionSec: 395 * 24 * 3600, suffix: ':1h' }, // downsampled
];
const MAX_POINTS = 1_100; // a 1,000px chart cannot usefully show more
function pickResolution(fromSec, toSec) {
const window = toSec - fromSec;
const age = Math.floor(Date.now() / 1000) - fromSec;
return (
RESOLUTIONS.find((r) => age <= r.retentionSec && window / r.stepSec <= MAX_POINTS)
?? RESOLUTIONS[RESOLUTIONS.length - 1]
);
}
const matchers = (labels) =>
Object.entries(labels).map(([k, v]) => `${k}="${v}"`).join(',');
function selector(metric, labels) {
const m = matchers(labels);
const shorthand = `${metric}{${m}}`;
// Identical query: a bare metric name is itself a matcher on the internal
// __name__ label, so these two selectors return the same series.
const longhand = `{__name__="${metric}"${m ? ',' + m : ''}}`;
return { shorthand, longhand };
}
// Rate windows must be at least a few scrape intervals wide, or a single missed
// scrape leaves gaps in the line.
function rateQuery(metric, labels, res) {
const { shorthand } = selector(metric + res.suffix, labels);
return `sum by (route) (rate(${shorthand}[${res.stepSec * 4}s]))`;
}
function p99Query(metric, labels, res) {
const { shorthand } = selector(`${metric}_bucket${res.suffix}`, labels);
// The aggregation happens INSIDE histogram_quantile, over the le label.
// Averaging each pod's p99 instead would produce a number with no meaning.
return `histogram_quantile(0.99, sum by (le) (rate(${shorthand}[${res.stepSec * 4}s])))`;
}
function plan({ metric, labels, fromSec, toSec, kind }) {
const res = pickResolution(fromSec, toSec);
return {
query: kind === 'p99' ? p99Query(metric, labels, res) : rateQuery(metric, labels, res),
step: res.stepSec, // sent as the range-query step, so the server
// returns ~1,000 points whatever the window is
};
}When to use it
- A dashboard that is instant over one hour and times out over 30 days.
- A metrics store that started OOMing the week someone added a customer id label.
- Choosing retention per resolution so a year of history costs about what a week of raw costs.
- Reporting p99 latency across 300 pods without averaging percentiles together.
More examples
Recording rules: pay at write time, not at dashboard time
Recording rules move cost from every dashboard load to one evaluation per interval, which is the single highest-leverage change on a slow dashboard with many viewers. Reach for them for any panel that aggregates across a dimension nobody looks at individually, and for anything an alert also evaluates - the alert and the panel should read the same precomputed series so they cannot disagree. The le trap in the second rule is the usual way a latency panel silently starts returning NaN.
groups:
- name: api-rollups
interval: 30s # evaluated on a schedule, independent of the scrape
rules:
# Collapses 40 pods into one series per route and status class. The
# dashboard then reads 100 series instead of 4,000, every refresh.
- record: job:http_requests:rate5m
expr: sum by (job, route, code_class) (rate(http_requests_total[5m]))
# Same idea for latency, but le MUST survive the aggregation or
# histogram_quantile has no distribution left to interpolate over.
- record: job:http_request_duration_seconds_bucket:rate5m
expr: sum by (job, route, le) (rate(http_request_duration_seconds_bucket[5m]))
# Error ratio precomputed once, so the SLO panel is a single series read
# rather than two range queries divided in the browser.
- record: job:http_errors:ratio5m
expr: |
sum by (job, route) (rate(http_requests_total{code_class="5xx"}[5m]))
/
sum by (job, route) (rate(http_requests_total[5m]))Pre-aggregating in the process, with a cardinality budget that bites
Shows where a cardinality limit belongs: at the point a series is created, in the process that creates it, where the offending label is still visible in a stack trace. Reach for in-process aggregation when request volume is far higher than the number of distinct label combinations - which is almost always true for HTTP metrics and almost never true for anything keyed by user.
// Agent-side aggregation: the application counts in memory and flushes one
// point per series per interval, instead of shipping one event per request.
// At 50,000 requests/second this is the difference between 50,000 writes/s and
// a few hundred.
const counters = new Map();
const MAX_SERIES = 5_000; // the budget, enforced where series are created
let dropped = 0;
function inc(metric, labels, by = 1) {
const key = metric + '|' + Object.keys(labels).sort()
.map((k) => `${k}=${labels[k]}`).join(',');
// Shed rather than grow. An unbounded label would otherwise take down the
// ingest path, and the breakage would surface as a dead dashboard, not as a
// bad label.
if (!counters.has(key) && counters.size >= MAX_SERIES) { dropped++; return; }
counters.set(key, (counters.get(key) ?? 0) + by);
}
// Route templates, never raw paths: /orders/8123 and /orders/8124 must share a
// series, or every order id becomes one.
inc('http_requests_total', { route: '/orders/:id', code_class: '2xx' });
setInterval(() => {
// Export the shedding as its own metric and alert on it, otherwise silent
// data loss looks exactly like a quiet service.
inc('metrics_series_dropped_total', {}, dropped); dropped = 0;
flush([...counters]);
counters.clear(); // delta counters; the backend accumulates
}, 10_000);Sketches for the questions exact counting cannot afford
Reach for sketches when the exact answer requires keeping the raw events and the question only needs three significant figures - unique counts, quantiles, heavy hitters. The discipline they impose is useful on its own: once every number on the dashboard is something mergeable, widening the time window or adding a region becomes a union of precomputed state rather than a rescan of raw data.
// Distinct active users per minute. An exact answer means storing every user id
// per bucket; HyperLogLog answers in a fixed ~12 KB per key with a 0.81%
// standard error, which is well inside what a dashboard can show.
const minuteKey = (ts) => `dau:${Math.floor(ts / 60_000)}`;
await redis.pfAdd(minuteKey(Date.now()), String(userId));
const activeThisMinute = await redis.pfCount(minuteKey(Date.now()));
// Mergeability is the whole point: a day's unique users is the UNION of its
// minutes, not their sum, and summing would double-count every returning user.
await redis.pfMerge('dau:2026-10-01', last1440MinuteKeys);
const activeToday = await redis.pfCount('dau:2026-10-01');
// The same argument applies to latency across shards. A mergeable quantile
// sketch - t-digest, or DDSketch, which guarantees a bounded RELATIVE error on
// each quantile - can be computed per shard and merged centrally. Two shards'
// p99 values cannot be combined at all; their sketches can.
const global = shardSketches.reduce((acc, s) => acc.merge(s), newSketch());
console.log(global.quantile(0.99));
Discussion