The average is lying to you#
TL;DRthe 30-second version
- A metric is a number about a running system, sampled over time. There are four shapes: a counter (only goes up — total requests served), a gauge (goes up and down — memory in use right now), a histogram (a bucketed distribution, so you can ask for the p99 latency), and a summary (percentiles the client computed itself).
- The average hides the tail. If 99 requests take 10ms and one takes 2 seconds, the mean is ~30ms and looks healthy — but one user in a hundred waited two seconds. The p99 is what users feel; the mean is dominated by the common case.
- You can add counters across servers and you can merge histogram buckets across servers. You cannot average pre-computed percentiles — averaging two servers' p99 gives a number that means nothing. This is the classic dashboard bug.
- Every distinct combination of label values is a separate time series. Put a user_id on a metric and a thousand users become a thousand series; a million users become a million. That's cardinality explosion, and it's how a metric quietly bankrupts your storage.
- RED (Rate, Errors, Duration) tells you what to measure for a request-serving service. USE (Utilization, Saturation, Errors) tells you what to measure for a resource like a CPU or a disk.
Start with the paging story, because it's the whole point. Your service handled a hundred requests in the last second. Ninety-nine of them came back in 10 milliseconds. One of them took 2 full seconds — maybe it hit a cold cache, maybe it queued behind a slow query. The user on that one request is angry enough to tweet about it. Now: what does the average latency say?
Work it out. The total time is 99 × 10ms plus 1 × 2000ms, which is 990 + 2000, about 2990 milliseconds across 100 requests. Divide and the mean is roughly 30 milliseconds. Your dashboard shows 30ms and glows green. But a real user just waited 2 seconds, and if you get a thousand requests a second, that's ten furious users every second the graph calls healthy. The average didn't lie about the math — it answered a question nobody asked. You don't care about the typical request. You care about the worst ones users actually hit.
The four kinds of metric#
A service exposes its metrics as named numbers that a collector scrapes every few seconds. There are only four shapes, and picking the right one is most of the skill. Start with the two simple ones.
A counter is a number that only ever goes up. It counts events since the process started: total requests served, total errors, total bytes sent. It never decreases — the only reset is when the process restarts and it drops back to zero. You almost never read a counter's raw value; what you want is how fast it's climbing. So you apply a rate: 'requests_total went from 8000 to 8500 over the last 10 seconds' means 50 requests per second. The counter stores the running total; the rate turns it into the throughput you actually graph.
A gauge is a number that goes up and down. It's a snapshot of something right now: memory in use, number of active connections, queue depth, CPU temperature. Unlike a counter, its current value is the point — you read it directly, and you take its average, max, or min over a window. The test for which one you have: if it can decrease, it's a gauge; if it only accumulates, it's a counter. 'Requests served' is a counter. 'Requests in flight' is a gauge, because a request can finish and bring it back down.
The third shape is the histogram, and it exists to answer the percentile question the average couldn't. Instead of keeping one number, a histogram keeps a row of buckets. Each bucket is itself a counter: 'how many requests finished in under 10ms', 'under 50ms', 'under 100ms', 'under 1s', and so on. You pre-declare the bucket boundaries when you instrument the code. Every observation falls into a bucket and bumps its count. Because the buckets together describe the shape of the whole distribution, the collector can estimate any percentile from them — p50, p95, p99 — without ever storing a single raw measurement.
The fourth shape is the summary. It answers the same percentile question but computes the answer inside your application, over a sliding time window, and exposes the finished numbers — 'my p99 over the last 10 minutes is 340ms'. It hands you the quantile directly instead of buckets you have to interpret. That sounds strictly better, and for a single instance it often is. But it hides a trap that only shows up when you have more than one server, which is the whole next section.
Here are the four side by side. Read the last column first — it's the one that trips people up.
| Type | What it is | How you read it | Can you combine it across servers? |
|---|---|---|---|
| Counter | A total that only goes up | rate() — the per-second climb | Yes — sum the rates |
| Gauge | A value that goes up and down | current value, or avg/max over a window | Sometimes — sum or average, depending on meaning |
| Histogram | Observations sorted into buckets | estimate p50/p95/p99 from the buckets | Yes — add the bucket counts, then compute the percentile |
| Summary | Percentiles the client already computed | read the quantile directly | No — you cannot average pre-computed percentiles |
The aggregation trap: never average a percentile#
You run the same service on twenty machines. Each one emits its own metrics. The dashboard has to fold twenty streams into one number per graph — and whether that's correct depends entirely on the metric type. This is where the histogram and the summary, which looked interchangeable, split apart.
Counters are easy: throughput is additive, so the fleet's request rate is the sum of each server's rate. Twenty servers at 50 requests a second is 1000 a second. Nothing subtle there.
Histograms are almost as easy, and this is their superpower. A histogram is just a set of bucket counts, and counts add. To get the fleet-wide latency distribution you add bucket 'under 10ms' across all twenty servers, add bucket 'under 50ms' across all twenty, and so on. That gives you one merged histogram describing every request the fleet served — and now you compute the true p99 over all of it. Merge first, then compute the percentile. It's exactly right.
That's the real distinction between the two percentile types. A histogram ships the raw material (bucket counts) and lets the server merge and then compute, so it aggregates correctly across a fleet. A summary ships the finished answer per instance, which is cheaper to store and accurate for one machine, but mathematically un-mergeable. This is why Prometheus and most large deployments default to histograms: at fleet scale, mergeability beats a slightly nicer single-instance number. The mergeable-sketch idea has its own topic — the t-digest simulator (/tdigest/sim) shows how you can estimate a p99 over a huge stream from a small structure that you can still merge, which is the property a raw percentile lacks.
PredictYour dashboard shows the fleet p99 by taking each of 20 servers' reported p99 and averaging them. One server is nearly idle but occasionally very slow. Is the number trustworthy, and what's the fix?
Hint: What information does a percentile throw away that you'd need to recombine it correctly?
No, it's not trustworthy — averaging percentiles is meaningless, and the idle-but-slow server makes it visibly wrong. That server serves almost no traffic, so its high p99 stands on a handful of requests, but the average weighs it equally against a busy server's p99 that stands on millions. The result overstates the fleet tail (or, in other mixes, understates it) with no fixed direction of error. The fix is to stop shipping pre-computed percentiles and switch to histograms: each server exports bucket counts, the dashboard adds the buckets across all 20 servers into one merged histogram, and computes a single p99 over the combined counts. Merge the raw material first, compute the percentile last — never the other way around.
Cardinality: the label that blows up the bill#
Metrics carry labels — key-value tags that let you slice one metric many ways. A request counter isn't just requests_total; it's requests_total{method="GET", status="200", endpoint="/cart"}. Labels are what make metrics useful: you can graph errors by endpoint, or latency by region. But they hide a cost that scales in a way that surprises people, and it's the single most expensive mistake in monitoring.
Here's the rule that decides your bill: every unique combination of label values is a separate time series, stored and indexed on its own. One metric with 4 HTTP methods, 5 status codes, and 20 endpoints isn't one series — it's 4 × 5 × 20 = 400 series, each with its own samples over time. That's fine. The trouble starts when a label has many possible values.
Add a user_id label so you can break requests down per user. You have a million users. Now multiply: 400 × 1,000,000 = 400 million time series for a single metric. Each series costs memory (the monitoring system typically holds every active series in RAM), disk, and index space. A number that was 400 became 400 million because one label was unbounded. Monitoring systems fall over from exactly this — it's called a cardinality explosion, and it usually arrives as an out-of-memory crash on the monitoring host, not on the service you were watching.
There's a lighter-weight way to get the 'how many distinct users' number without paying per-user cardinality: estimate it. Counting unique values cheaply is its own problem, and the HyperLogLog simulator (/hll/sim) shows how you can count millions of distinct items in a couple of kilobytes instead of storing one series each. When you truly need a distinct-count, estimate it as a single gauge — don't reach for a per-value label.
PredictA metric has these labels: region (5 values), status (10 values), and — added last week to debug a customer issue — customer_id (you have 50,000 customers). Roughly how many time series is this one metric now, and what should you do?
Hint: Multiply the label cardinalities. Which one is unbounded, and where should that data live instead?
About 5 × 10 × 50,000 = 2.5 million time series for that one metric. Before the customer_id label it was 5 × 10 = 50 series — the new label multiplied it by 50,000. That's a cardinality explosion, and left in place it will balloon storage and likely OOM the monitoring server. The fix is to drop customer_id from the metric labels. If you genuinely need per-customer debugging, that data belongs in traces or logs where each event is independent, or behind a distinct-count estimate — not baked into a metric label where every customer forks a permanent series. Metric labels are for small, bounded dimensions; high-cardinality identity goes elsewhere.
What to measure: RED and USE#
Knowing the metric types tells you how to measure. It doesn't tell you what. A service has thousands of possible numbers; instrument all of them and you drown, instrument the wrong ones and you're blind. Two small frameworks answer 'what', and they split by what you're looking at: a request-serving service, or a physical resource.
RED is for a service — anything that handles requests, like an API, a gateway, or a microservice. Tom Wilkie named it at Weaveworks around 2016. For every service, measure three things:
- Rate — requests per second the service is handling (a counter you rate()).
- Errors — how many of those requests are failing (a counter, usually read as a fraction of the rate).
- Duration — how long requests take, as a distribution so you get the p99 (a histogram).
Those three answer 'is my service serving users well?' from the caller's side, and because every service reports the same three, one dashboard shape works for your whole architecture — you can put someone on call for a service they've never seen and they still know which three graphs to read.
USE is for a resource — a CPU, a disk, a memory pool, a network link. Brendan Gregg named it. For every resource, measure three different things:
- Utilization — what fraction of the time the resource is busy (a disk at 90% busy).
- Saturation — how much work is queued waiting because the resource is full (the run queue length).
- Errors — error events from the resource (dropped packets, disk errors).
The pairing is the point: RED watches the work (requests flowing through a service), USE watches the machinery (resources the work runs on). When a RED dashboard shows latency climbing, a USE dashboard on the underlying resources usually shows which one saturated. Use both — RED to notice users hurting, USE to find the bottleneck causing it.
Under the hood: how a histogram estimates a percentile
A histogram doesn't store your latencies, so how does it produce a p99? It interpolates. Say your buckets have boundaries at 100ms and 250ms, and you want the p99. The collector finds which bucket the 99th-percentile position falls into — suppose it lands inside the 100ms-to-250ms bucket — and then assumes the observations inside that bucket are spread evenly across its range. It reports a value somewhere between 100 and 250ms based on where in the bucket the 99th percentile sits.
That even-spread assumption is where the error comes from. If your true p99 is 110ms but the bucket runs 100–250ms, the estimate can be badly off, because real latencies aren't spread evenly inside a wide bucket — they cluster. The fix is to choose bucket boundaries tight around the values you care about: if your latency target is 200ms, put several buckets between 150 and 300ms so the p99 lands in a narrow one. A histogram is only as precise as its buckets near the percentile you're asking about. This is the trade a summary avoids by computing the quantile from raw values on the client — at the cost of being un-mergeable, from the aggregation section.
In the wild
- Prometheus is the reference implementation of this exact model — counter, gauge, histogram, summary are its four core types, and its documentation is explicit that you should prefer histograms over summaries precisely because histograms aggregate across instances and summaries don't.
- Grafana dashboards are where RED graphs live for most teams; Grafana Labs (where Tom Wilkie went after Weaveworks) publishes the RED method as its default service-instrumentation pattern.
- Datadog, New Relic, and other commercial platforms bill heavily by cardinality — the number of distinct tag combinations — which is why 'a high-cardinality tag' is a line item that shows up on a monitoring invoice, not just an engineering abstraction.
- Google's SRE book codified the four golden signals from running production at Google scale; the RED and USE methods are both descendants that make the same idea usable for a specific kind of thing you're watching.
- The metrics-monitoring system design (the whole ingest-and-store pipeline) is the system that has to survive exactly the cardinality problem described here — this page is the instrumentation side, that one is the storage side.
Pitfalls & gotchas
Why not just alert on the average latency? It's simpler.
Because the average is dominated by the common case and blind to the tail, which is the part users feel. A service can hold a 30ms average while one request in a hundred takes 2 seconds — and at scale that 1% is thousands of angry users the average never shows. Alert on a percentile (p99 or p95) so a slow tail actually trips the page.
Is a summary just a better histogram? It gives me the exact p99.
For one instance, yes — a summary computes the quantile from raw values, so it's more accurate than a histogram's bucket interpolation. But you can't merge summaries across servers: averaging pre-computed percentiles is meaningless. A histogram trades a little single-instance precision for the ability to add bucket counts across a fleet and compute a correct global percentile. At more than one server, that trade almost always favors the histogram.
What actually happens when cardinality explodes?
The monitoring system, not the monitored service, runs out of memory. Most systems keep every active time series in RAM, and each unique label combination is a series. Add an unbounded label like user_id and the series count jumps by the number of users; the collector's memory climbs until it's killed. The service you were watching is fine — you've knocked over the thing watching it.
My counter's graph shows a huge negative spike. Bug?
Almost always a process restart. A counter resets to zero when the process restarts, so a raw graph shows it fall off a cliff. The rate() function is built to detect this: a drop is read as a reset, not as negative throughput, and the rate stays correct across the restart. If you're seeing the negative spike, you're graphing the raw counter value instead of its rate — graph the rate.
Should I pick histogram bucket boundaries once and forget them?
No — they decide your percentile accuracy. A percentile that lands in a wide bucket is interpolated across that whole range and can be far off. Put your buckets tight around the latencies you care about (your latency target), so the p99 lands in a narrow bucket. Default buckets from a library are a starting point, not a fit for your service.
QuizA team fans out one service across 30 pods and builds a latency dashboard by taking each pod's exported p99 and averaging the 30 values. Traffic is very uneven across pods. What's wrong, and what's the correct design?
- Nothing — averaging percentiles is how fleet p99 works.
- Averaging p99s is meaningless; export histograms, add bucket counts across pods, then compute one p99 over the merged buckets.
- The problem is too few pods — add more and the average converges to the true p99.
- Switch to averaging the p50 instead; medians are safe to average.
Show answer
Averaging p99s is meaningless; export histograms, add bucket counts across pods, then compute one p99 over the merged buckets. — Averaging pre-computed percentiles is mathematically meaningless — a percentile is a position in a distribution, not an additive quantity, and it discards how many requests it stood on. With uneven traffic it's visibly wrong: a low-traffic pod's p99 stands on a few requests but is weighted equally against a busy pod's p99 that stands on millions. The correct design is to export the latency as a histogram, add the bucket counts across all 30 pods into one merged histogram, and compute a single p99 over the combined counts. Merge the raw material first, compute the percentile last. No amount of extra pods fixes an average of percentiles, and medians are percentiles too — equally un-averageable.
In an interview
Metrics questions reward showing you understand the tail and the traps, not reciting four type names. The strong answers all come back to one idea: an average hides what users feel, so you keep distributions and you keep them mergeable.
- Lead with the tail: name p99 (and why), not the average. If asked what to alert on for a service, say 'the p99 latency and the error rate' — the trap is answering 'average latency', which hides the slow requests users actually hit.
- Know the four types and, more importantly, which combine across servers: counters sum, histogram buckets merge, gauges depend, and pre-computed percentiles (summaries) do not. The trap is claiming you can average two servers' p99 — that's the classic wrong answer.
- Explain the histogram-vs-summary trade in one line: histograms aggregate across a fleet (add buckets, then compute), summaries are accurate per-instance but un-mergeable. That's why large deployments default to histograms.
- Raise cardinality before you're asked: 'I'd keep labels bounded — no user_id or request_id as a label, because every distinct value is a new time series and that's how you OOM the monitoring system.' Naming this unprompted signals real operational experience.
- Frame what to measure with RED (Rate, Errors, Duration) for services and USE (Utilization, Saturation, Errors) for resources, and tie both back to Google's four golden signals — page on symptoms users feel, use resource saturation to explain them.
PredictAn interviewer asks: 'You're designing metrics for a payments API. What do you export, and what's the one label you refuse to add?' What's the strong answer?
Hint: Which framework fits a request service, which metric type keeps the p99 mergeable, and which label is unbounded?
Frame it with RED, because it's a request-serving service. Export a request rate counter, an error counter (split by a bounded error class, not the message), and — the important one — request duration as a histogram, not a summary, so you can compute a correct p99 across every instance and alert on the tail users feel. Pick bucket boundaries tight around the latency target so the p99 is accurate. Then name the label you refuse to add: anything unbounded and identity-shaped — transaction_id, user_id, card fingerprint. Each distinct value forks a permanent time series, so a per-transaction label would explode cardinality and eventually OOM the monitoring system; that per-payment detail belongs in traces or logs, where each event is independent. The one-line version: RED with a histogram for duration, bounded labels only, high-cardinality identity goes to tracing.
References
- Prometheus — Metric Types — The canonical definitions of counter, gauge, histogram, and summary.
- Prometheus — Histograms and Summaries — Why to prefer histograms: bucket interpolation, and the aggregation reason summaries don't merge.
- The RED Method (Grafana / Tom Wilkie) — Rate, Errors, Duration — instrumenting a request-serving service.
- The USE Method (Brendan Gregg) — Utilization, Saturation, Errors — analyzing a resource's performance.
- Google SRE Book — Monitoring Distributed Systems — The four golden signals: latency, traffic, errors, saturation.
- Prometheus — Metric and Label Naming — Guidance on labels and keeping cardinality bounded.