open atlas
↑ Back to track
NestJS, zero to senior NEST · 07 · 04

Metrics, percentiles and SLOs

Metrics are not logs or traces: they are cheap aggregates you alert on. Pick the right type (counter/gauge/histogram), measure the tail with p99 not the lying average, keep labels low-cardinality or Prometheus OOMs, and alert on SLO budget burn.

NEST Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The dashboard was green. Average request latency on the checkout route sat at a flat 90ms, the same line it had drawn for months, and the on-call engineer glanced at it and went back to lunch. Meanwhile support tickets piled up: “the page hangs”, “I clicked pay twice and got charged once”, “it spins forever”. The average never moved because it could not — a mean is anchored by the dense middle of the distribution, and only the slowest one request in a hundred had blown out to four seconds. That one-in-a-hundred was real users hitting the 3-second client timeout, retrying, and doubling the load that was causing the slowness in the first place. The fix was not a code change first; it was a measurement change: stop watching the average, start watching p99, and put an SLO on it. This lesson is about getting metrics right — the types, the percentiles, the label that takes Prometheus down, and the budget you actually alert on.

The four metric types: pick the one that answers your question

A metric is not a log line and not a span. It is a number sampled over time and aggregated server-side — cheap to store, cheap to query, and the thing you build alerts on. Prometheus gives you four types, and choosing wrong is the first mistake.

import { Counter, Gauge, Histogram } from 'prom-client';

// COUNTER: monotonic, only ever goes up (or resets to 0 on restart).
// You never read its raw value — you query rate() over it.
const httpRequests = new Counter({
  name: 'http_requests_total',
  help: 'Total HTTP requests',
  labelNames: ['method', 'route', 'status'] as const, // LOW cardinality — see below
});

// GAUGE: a value that goes up AND down — a snapshot of "right now".
const inFlight = new Gauge({
  name: 'http_requests_in_flight',
  help: 'Requests currently being served',
});

// HISTOGRAM: samples a value (here, duration) into pre-defined buckets,
// so you can compute quantiles SERVER-SIDE across all instances.
const httpDuration = new Histogram({
  name: 'http_request_duration_seconds',
  help: 'Request duration in seconds',
  labelNames: ['method', 'route', 'status'] as const,
  buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 2, 5], // boundaries decide p99 accuracy
});

A counter is monotonic — request totals, error totals. You never graph the raw number; you graph rate(http_requests_total[5m]) to get requests-per-second. A gauge moves both ways — in-flight requests, memory usage, queue depth, connection-pool size: a snapshot you read directly. A histogram drops each observation into a bucket; because the buckets live on the server you can compute histogram_quantile(0.99, …) and, crucially, add the buckets across all your instances first and then take the quantile. The fourth type, summary, computes quantiles client-side in each process — which means you cannot aggregate them: the p99 of three instances is not the average of their three p99s. In any multi-instance service, prefer histograms.

In Nest you wire this with @willsoto/nestjs-prometheus (or raw prom-client), expose a /metrics endpoint for Prometheus to scrape, and record durations in an interceptor that wraps every request — start a timer in intercept, stop it in the tap/finalize of the observable, labelling by method, route template, and status.

RED and the golden signals: what to actually measure

You do not measure everything; you measure the handful of signals that tell you whether the service is healthy. For a request-driven service the RED method is the spine: Rate (requests per second, from the counter), Errors (failed requests per second, the same counter filtered to 5xx), Duration (the latency distribution, from the histogram). Google’s four golden signals add saturation (how full the busiest resource is) to latency, traffic, and errors. USE (Utilization, Saturation, Errors) is the resource-side mirror — apply it to CPU, memory, disk, the connection pool. RED for the request path, USE for the resources it leans on.

Percentiles: the average lies, measure the tail

Here is the core insight the opening incident turns on. The average latency hides the outage that p99 exposes. A mean of 80ms can sit on top of a p99 of two seconds, because the mean is dominated by the bulk of fast requests and a small slow tail barely nudges it. But that tail is not noise — it is real users, and it is exactly the cohort that hits timeouts, fires retries, and amplifies load. An SLO on the average is an SLO on the wrong number.

# PromQL: p99 latency per route, aggregated across ALL instances.
# Note the sum by (...) BEFORE histogram_quantile — this is why you use
# a histogram and not a summary: you add the buckets first, then quantile.
histogram_quantile(
  0.99,
  sum by (le, route) (rate(http_request_duration_seconds_bucket[5m]))
)

So you track p50 (the typical user), p95 and p99 (the tail that hurts), and p999 once your traffic is large enough for one-in-a-thousand to be a meaningful number of people. One caveat: a Prometheus histogram quantile is only as good as your bucket boundaries. histogram_quantile linearly interpolates within the bucket the quantile falls in, so if your p99 lands between a 1s and a 5s bucket, the reported value is a guess somewhere in that gap — possibly very wrong. Put bucket boundaries where you actually care (around your SLO threshold), not on round numbers.

Why this works

Why does the average hide an outage that the p99 exposes? Because the mean is a center-of-mass: it is pulled by the dense middle of the distribution, where most requests sit. If 99 requests take 50ms and one takes 4 seconds, the average is about 90ms — the one slow request barely moves it. But that one slow request is not a rounding error: at a thousand requests a second it is ten users a second hitting a hard wall, and at a client timeout of 3s they retry, which adds load and makes the tail worse. The pain of a system lives in its tail, so you must measure the tail directly — p99, p999 — not a number that is mathematically designed to erase it. An SLA or SLO defined on the average is an SLO on the wrong number, and it will read green through a real, user-visible outage.

Cardinality: the label that takes Prometheus down

This is the failure mode that kills the monitoring system itself. In Prometheus, every distinct combination of label values is its own time series, stored and indexed separately. http_requests_total{method="GET", route="/users/:id", status="200"} is one series; change any label value and you get another. That is fine when labels are bounded — there are a handful of methods, a few dozen routes, a few status classes, so the product is maybe a few hundred series.

It becomes catastrophic the instant you put a high-cardinality label on a metric: userId, requestId, email, or a raw path that embeds ids. A userId label means one series per user — a million users is a million series per metric, each with its own memory and index entry. Prometheus holds its head series in RAM; cardinality explosion OOMs the server, balloons scrape time and storage, and takes monitoring down exactly when you need it.

// CARDINALITY BOMB — raw path with embedded ids becomes one series PER id.
// /users/1, /users/2, … /users/9999999 → millions of distinct label values.
httpRequests.inc({ method: 'GET', route: req.path, status: '200' });
// e.g. route="/users/8f3a-...-91/orders/4412"  ← unbounded, unique per request

// SAFE — collapse to the route TEMPLATE; the id lives in the value, not the label.
httpRequests.inc({ method: 'GET', route: '/users/:id/orders/:orderId', status: '200' });

The rule: labels must be low-cardinality and bounded — method, route template (not the raw path), status class. When you feel the urge to add a userId or requestId label so you can “drill down” per-user, stop: that id belongs in a trace or a log line, not a Prometheus dimension. The thing you were tempted to put in a label (the user id, the request id) belongs in a trace or a log line, not a metric dimension. The war-story is mundane and common: someone labelled http_requests by full URL including literal /users/{id} ids, the series count exploded, Prometheus ate all its RAM and fell over — and the dashboard went dark mid-incident.

SLOs and the error budget: what you alert on

An SLO is a target: “99.9% of checkout requests complete in under 300ms, measured over 30 days.” Its mirror image is the error budget — the 0.1% you are allowed to miss. That budget is not a failure to avoid; it is a resource to spend. Burn it slowly and you have room to ship features and take risks; burn it fast and you freeze deploys and fix reliability. And you do not alert on the raw error count — a handful of errors at 3am is noise. You alert on the burn rate: how fast you are consuming the budget. A burn rate that would exhaust a 30-day budget in an hour is a page; one that would take a week is a ticket. That is the difference between waking someone up and not.

Pick the best fit

You need to track HTTP request latency for an SLO (99.9% under 300ms) across a service running on many instances, and compute p99 on dashboards. Which metric and labels?

Quiz

You want to track HTTP request latency and report a correct p99 across a service running on 12 instances. Which metric type, and why?

Quiz

An engineer adds a `userId` label to the http_requests_total counter so they can break traffic down per user. The service has ~2 million users. What happens to Prometheus?

Recall before you leave
  1. 01
    Name the four Prometheus metric types, what each is for, and why you prefer a histogram over a summary for request latency in a multi-instance service.
  2. 02
    Explain why the average latency hides outages, what a cardinality explosion is and how to avoid it, and what an SLO error budget is and what you alert on.
Recap

Metrics are cheap server-side aggregates you build alerts on, distinct from logs and traces. Prometheus has four types: a counter is monotonic (request and error totals — you query rate() over it), a gauge moves both ways (in-flight, memory, queue depth), a histogram buckets observations so you can compute quantiles server-side, and a summary computes quantiles client-side and so cannot be aggregated — prefer histograms in any multi-instance service. Measure the RED signals for the request path (Rate, Errors, Duration) and USE for resources. The core insight: the average latency lies — a mean is pulled by the dense middle, so a p99 of seconds can hide under a flat 90ms average while real users hit timeouts and retry; track p50/p95/p99/p999 and put the SLO on the tail, remembering that histogram_quantile only interpolates as well as your bucket boundaries allow. The killer failure mode is cardinality: every label-value combination is a separate series, so a userId, requestId, or raw-path label creates millions of series and OOMs Prometheus — labels must be bounded (method, route template, status), and the id goes in a trace, not a label. Finally, an SLO is a target (99.9% under 300ms over 30 days → a 0.1% error budget ≈ 43 min/month) and you alert on budget burn rate, not raw error count, so a fast burn pages and a slow burn is a ticket. Now when you see a dashboard that reads “90ms average, all green,” you know to check p99 — because that is where the outage your users are experiencing is hiding.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.