Hotshard
Observability

Metrics: Counters, Gauges & Histograms

What would you actually alert on — and why is the average lying to you?

It's 3am and you're paged: users say the app is slow. You open the dashboard and the average request latency reads 40 milliseconds. That's fast. So who's lying — the users or the graph? The users. An average is a single number smeared across every request, and it hides the slow ones inside a sea of fast ones. This page is about the small toolkit that lets you see what the average hides: the four kinds of metric a service emits (counters, gauges, histograms, summaries), why you measure the tail of your latency and not the middle, the one aggregation mistake that quietly corrupts a fleet-wide dashboard, the label that can blow up your monitoring bill by orders of magnitude, and the three-letter frameworks (RED and USE) that tell you what to measure in the first place. No new database — just how to instrument the ones you have.

~12 min read

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 number you want is the percentileThe p99 (99th percentile) is the latency that 99% of requests came in under — so 1% were slower. In the story above the p99 is 2 seconds: it names the pain the average buried. p50 (the median) is the typical request, p95 and p99 are the tail, p999 (the 99.9th) is the rare disaster. To compute any of these you need to know the shape of the whole distribution, not just its sum and count — and that's exactly what a histogram keeps and an average throws away. The Numbers page has a latency-percentiles simulator (/numbers/sim) if you want to feel the gap between the mean and the tail.

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.

Why the counter/gauge split mattersIt decides how the collector treats a missing or restarted value. A counter that jumps from 8500 back to 30 is read as 'the process restarted', not 'throughput went negative' — the rate function knows to handle the reset. A gauge dropping is just the value dropping. Label a decreasing quantity as a counter and every dip looks like a restart; label an accumulating one as a gauge and you lose the reset handling. The shape is a contract with the tools.

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.

TypeWhat it isHow you read itCan you combine it across servers?
CounterA total that only goes uprate() — the per-second climbYes — sum the rates
GaugeA value that goes up and downcurrent value, or avg/max over a windowSometimes — sum or average, depending on meaning
HistogramObservations sorted into bucketsestimate p50/p95/p99 from the bucketsYes — add the bucket counts, then compute the percentile
SummaryPercentiles the client already computedread the quantile directlyNo — 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.

Why you cannot average two p99sA percentile is a position in a distribution, not a quantity you can add. Server A served a million fast requests and its p99 is 20ms. Server B served ten requests and its p99 is 5 seconds. The average of 20ms and 5s is about 2.5 seconds — but that number describes no real request anywhere: the fleet is overwhelmingly fast, its true p99 is close to 20ms. Averaging threw away how many requests each percentile stood on. Once a percentile has been computed, the counts behind it are gone, and you can never correctly recombine it. A summary hands you a finished percentile per server, so a multi-server summary dashboard is doing exactly this broken average — usually without anyone noticing.

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.

The rule of thumb for labelsA label is safe only if its set of possible values is small and bounded — HTTP method (a handful), status code (dozens), region (a few). A label is dangerous if its values are effectively unlimited: user_id, email, session token, request ID, full URL with query string, or a raw timestamp. These belong in logs or traces, where each event stands alone, not in metric labels, where each distinct value forks a new permanent series. The question to ask before adding a label: how many distinct values can this ever take? If the answer is 'unbounded', it is not a label.

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.

Where the golden signals fitGoogle's SRE (site-reliability engineering) book predates both with the four golden signals: latency, traffic, errors, and saturation. RED is essentially those signals aimed at a request service (traffic → rate, latency → duration, plus errors), dropping saturation because a service's saturation shows up as rising duration. USE aims the same instinct at resources instead. They're not competing systems — they're the same short list of 'the few things worth paging on', framed for whatever you happen to be measuring. If you remember one sentence: page on symptoms users feel (latency, errors), and use resource saturation to explain them.
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.

Exemplars: from 'p99 spiked' to the actual slow requestA histogram tells you the p99 jumped but not which request caused it — the raw measurements are gone. Exemplars patch this: alongside a bucket's count, the system stores a pointer (a trace ID) to one example observation that landed in that bucket. When the p99 bucket lights up, you click through to a real trace of one slow request and see where its time went. It's the bridge from the aggregate metric back to a single concrete event — the seam between metrics and tracing.
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?

  1. Nothing — averaging percentiles is how fleet p99 works.
  2. Averaging p99s is meaningless; export histograms, add bucket counts across pods, then compute one p99 over the merged buckets.
  3. The problem is too few pods — add more and the average converges to the true p99.
  4. 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
References

Feedback on this topic →