LearnHLDDesign a metrics system

Design a metrics and monitoring system

The system that tells you everything else is broken has an unusual requirement: it has to be the last thing standing. It also has an unusual workload, because the writes never stop and never slow down. Every server, every second, forever, whether anyone is looking or not.

The thing that kills these systems is not volume. It is cardinality, and it kills them suddenly, from a one line code change nobody reviewed carefully.

Your answer

An engineer adds a user id label to an existing metric so they can debug one customer. What happens to your storage, and why is it worse than it sounds?

What you are building

In scope
  • Ingest numeric time series from tens of thousands of hosts. Counters, gauges and histograms, at second or ten second resolution.
  • Query and aggregate over time ranges. Both a dashboard covering a month and an alert covering five minutes.
  • Evaluate alert rules continuously. This is the part that has to work during an incident.
  • Retain data with declining resolution. A year of raw seconds is neither affordable nor useful.
The numbers you commit to
  • 10 million active time series.
  • 20 million data points per second at peak.
  • Alert evaluation within 30 seconds of the data arriving.
  • The monitoring system must not depend on the systems it monitors.
Cut, and say so out loud
  • Distributed tracing and logs. Different shapes, different storage, and each is its own design question.
  • Anomaly detection. It sits on top of the query layer.
  • Incident management and paging workflow, which is a product rather than an infrastructure problem.

The numbers, and the one that bites

Cardinality
20 thousand
500
1
Active time series20k x 500 x 1 = 10,000,000
Points per second at 10s resolution1,000,000
Raw per day at 16 bytes a point1.4 TB
Compressed, roughly 10x138 GB
Index memory at ~1KB a series10.0 GB
10,000,000
series, before anyone adds an interesting label

Now drag the third slider. It represents one engineer adding one label to one metric, and it multiplies everything above.

Drag the third slider from 1 to 200 and watch. That slider is one engineer adding customer_id to a metric so they can debug one account. It is a one line change, it passes review, and it multiplies the entire system by two hundred.

Why cardinality hurts more than volume

Doubling the points per series costs you disk roughly linearly, and compression absorbs a lot of it. Doubling the number of series doubles the index, doubles the open write buffers, doubles the compression state held in memory, and makes every query touch twice as many streams. Volume is a storage problem. Cardinality is a memory problem, and memory is the thing you run out of suddenly rather than gradually.

Push or pull

Collection
The collector knows what should exist, so a target that stops responding is itself a signal: you can alert on a service being absent, which push can never do because a dead service simply stops sending. It also gives you natural back pressure, since the collector controls the rate. The cost is service discovery, and difficulty reaching things behind NAT or firewalls, or short lived jobs that finish before a scrape.

Storing time series

A time series is one of the most compressible things in computing, and exploiting that is what makes the whole system affordable.

Timestamps arrive at nearly fixed intervals, so store the delta of the delta, which is usually zero and costs a bit or two. Values in a series usually change slowly, so XOR each value with the previous one and store only the differing bits. Together these routinely get a 16 byte point down to under two bytes.

series and chunkscolumn oriented, immutable chunks per time window
series_idbigintPKA hash of the metric name plus its full sorted label set. This identity is the cardinality.
labelsinverted indexIDXLabel to series id postings, so a query filters without scanning. This lives in memory and is what cardinality inflates.
chunkcompressed blockTwo hours of one series, delta of delta timestamps and XOR values. Immutable once closed.
head blockin memoryThe current open window. Every active series holds one, which is the other memory cost of cardinality.
walappend onlyWrite ahead log so a crash does not lose the head block.
Sample row
series 8814 = http_requests{svc="api",code="500"} | chunk 14:00-16:00 | 1,240 points in 2.1KB
Note where memory actually goes: the label index and one open head block per active series. Both scale with the number of series and neither scales with how long you retain data, which is exactly why cardinality and volume fail in completely different ways.

Deep dive one: cardinality in practice

Where the series are
14
12
13
13
12
12
13
12
0
1
2
3
4
5
6
7
Metric 0 carries 14.0% (even would be 12.5%)
Series spread across metrics roughly evenly, because every metric is labelled by things with small bounded value sets: service, instance, status code, region. This is what you designed for.

The lesson is that you cannot prevent this at design time, so design for containment instead. Per metric and per tenant series limits, a cardinality report that names the worst offenders, and an alert on the growth rate rather than the absolute number, because the absolute number is fine right up until it is not.

Deep dive two: retention as a pipeline

Nobody queries per second data from eight months ago, and storing it costs the same as storing today’s.

Downsample on a schedule. Raw resolution for a couple of days, one minute for a month, one hour for a year, and for each of those keep several aggregates rather than one: minimum, maximum, sum, count. Keeping only the average is the mistake, because averaging averages of different sized buckets is wrong, and the maximum is usually the number that mattered.

Figure 1. Data moves rightwards over time, getting coarser and cheaper. Queries pick the resolution from the range asked for.

A query for the last thirty minutes reads raw. A query for the last quarter reads hourly rollups. The query layer picks based on the range and the pixel width of the chart, because returning three million points to draw an eight hundred pixel wide graph is work nobody benefits from.

Deep dive three: the alerting path must be independent

During an incident, the metrics system is being hammered by every engineer loading dashboards at once. That is exactly the moment alert evaluation must not slow down.

So separate them. Alert rules run on their own evaluators against their own read path, with their own capacity, and they are not competing with a hundred people running expensive ad hoc queries. A dashboard being slow during an incident is annoying. Alerts being late during an incident means nobody knows there is an incident.

The deeper version of the same rule: the monitoring system must not depend on the systems it monitors. If it authenticates through the same identity service, uses the same service discovery, and runs on the same cluster, then a failure of any of those makes the system blind precisely when it is needed. This is the question that separates people who have been on call from people who have not.

Break it

Ingest health
normal
normalcardinality spikeingesters OOMincident loadcontained
Healthy. 10 million series, 20 million points a second, alerts evaluating every 15 seconds well inside their window. Compression is doing most of the work and nobody is thinking about the monitoring system.

The API

GET/v1/query_range?expr=rate(http_requests{svc="api"}[5m])&start=...&end=...&step=60
returns 200 { series: [...], pointsScanned: 1840221, truncated: false }
Why: The response reports how much work it did. In a system where one careless query can scan a hundred million points, making cost visible in every response is how expensive dashboards get found before they become an outage.
POST/v1/write
{ "series": [{ "labels": {...}, "samples": [[ts, value], ...] }] }
returns 204, or 429 with the offending metric named
Why: A rejection has to say which metric hit its series limit. A generic 429 during a cardinality explosion sends an on call engineer hunting through thousands of metrics at the worst possible time.
GET/v1/cardinality?top=20
returns 200 { metrics: [{ name, series, growthPerHour }] }
Why: Growth rate matters more than the absolute number. A metric at two million series and stable is fine. One at fifty thousand and doubling every ten minutes is the deploy that is about to take the system down.

Trade-offs

ChoiceWhat you gainWhat you payPick it when
Pull over pushAbsence is detectable, because the collector knows what should exist, and back pressure stays with the collector.Service discovery to maintain, and short lived jobs need a gateway.Long lived services you control. Use a push gateway as a clearly marked exception.
Delta of delta and XOR compressionAround ten times smaller, which changes what is affordable rather than just being an optimisation.Points must be appended in time order, and out of order writes are expensive or rejected.Always for regular interval numeric series. It is the reason this workload is tractable.
Per metric series limitsA cardinality explosion degrades one metric instead of the whole system.Data is silently missing for the metric that hit the cap, which is confusing if the alert is missed.Always, in any multi team system. Someone will add an unbounded label, and the only question is when.
Separate alert evaluation capacityAlerts keep firing on time while everyone is loading dashboards during an incident.More infrastructure, duplicated read capacity that is idle most of the time.Any system where alerts page humans. This is the capacity you are paying for precisely when everything else is on fire.

Interview replay

Interviewer
What is the hardest part of running this at scale?
Open. The answer they are listening for is a specific word.
You
Cardinality. Volume is a storage problem and compression handles most of it, but every distinct label combination is a separate series with its own index entry, its own open write buffer and its own compression state, and all of that is memory. One engineer adding a user id label to an existing metric can multiply the series count by a thousand in a single deploy, and memory is the thing you run out of suddenly rather than gradually.
Names it in the first word, then explains the mechanism rather than just the symptom.
Interviewer
Can you prevent it?
The interesting follow up, because the honest answer is no.
You
Not at write time, because there is no way to tell an unbounded label from a legitimate one when the first few values arrive. So I would contain it instead: a per metric series limit that rejects new label combinations past a threshold and alerts naming the metric, plus a cardinality report ranked by growth rate rather than absolute count. One bad metric then degrades itself instead of the system.
Saying prevention is impossible and moving to containment is the senior move. Growth rate over absolute count is the detail that shows operational experience.
Interviewer
Push or pull for collection?
A preference question with one strong argument on the pull side.
You
Pull for anything long lived, mainly because absence becomes observable: the collector knows what should be there, so a target that stops responding is a signal, whereas with push a dead service looks identical to one that was never configured. Pull also keeps back pressure on the collector rather than letting a service in a retry loop flood ingest. I would add a push gateway for short lived batch jobs, and keep it clearly an exception rather than a second default.
The absence argument is the one that decides it, and it is stated first.
Interviewer
It is 3am, there is a major incident, and everyone is loading dashboards. What breaks?
The question that separates people who have carried a pager.
You
Alert evaluation, if it shares a read path with dashboards. A hundred expensive ad hoc queries saturate the query layer and alert rules start evaluating late, so alerts for the incident fire minutes after they should. I would give alert evaluators separate capacity and their own read path. More generally the monitoring system must not depend on what it monitors: same cluster, same identity service, same service discovery, and it goes blind exactly when you need it.
Answers the immediate case and then generalises to the independence principle. The generalisation is what makes it a staff level answer.
Interviewer
How long would you keep raw resolution data?
Checking whether retention is thought of as a pipeline or a setting.
You
A couple of days, then downsample to one minute for a month and one hour for a year. The important detail is keeping minimum, maximum, sum and count in each rollup rather than just the average, because averaging averages across unequal buckets is wrong and the maximum is usually the number someone actually cared about. I would set the exact windows from what people query, which I would measure rather than guess, since query ranges cluster far more tightly than anyone expects.
The min max sum count detail is small, specific and very hard to fake.

Checkpoint

Checkpoint

1. Why does adding a high cardinality label hurt more than simply storing more data points?

2. What can pull based collection detect that push cannot?

3. Why keep min, max, sum and count in a downsampled rollup rather than just the average?

Say this in 60 seconds

The workload is relentless writes and bursty reads, and the thing that actually kills these systems is cardinality rather than volume, because every distinct label combination is a separate series holding an index entry, an open write buffer and compression state, all in memory. I would collect by pull for long lived services, since that makes absence detectable and keeps back pressure with the collector, with a push gateway as a marked exception for short jobs. Storage is immutable compressed chunks per time window using delta of delta timestamps and XOR values, which is roughly ten times smaller and is what makes this affordable at all. Retention is a downsampling pipeline, raw for days then minute then hour, keeping min, max, sum and count rather than just the average. I cannot prevent a cardinality explosion at write time, so I would contain it with per metric series limits that alert naming the offending metric, and I would run alert evaluation on separate capacity, because the moment everyone is loading dashboards is exactly the moment alerts must not be late.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.