Design Metrics Monitoring
Collect metrics from 100,000 servers, store them compactly, serve dashboards and fire alerts that still work when the monitored systems are failing.
On this page
Every server in a company reports numbers every few seconds: CPU, memory, requests served, errors, latency. Engineers look at them on dashboards, and rules watch them around the clock and page someone when error rates climb. When production is on fire, this is the system everyone opens at once, so it must be at its best exactly when everything else is at its worst.
Think of an in-house version of a hosted product such as Datadog. Interviewers ask it because it looks like "store numbers, draw graphs" and hides a write-heavy store that must compress well, a cardinality problem that can take it down, and a design rule most candidates miss: the monitor must not fail along with what it monitors. The observability lesson covers what to measure and how to alert well; this one builds the machine. The steps follow A Framework for Any Design.
Clarify the problem
Design a metrics monitoring system.
Metrics only, meaning numeric time series, or logs and traces too?
Metrics only.
How many servers, how many series per server, and how often do they report?
100,000 servers, about 500 series each, every 10 seconds.
How long do we keep data, and at what detail?
Full detail for a week, coarser data for two years.
Then writes dominate and are steady, and reads spike during incidents. My priorities: alerts must fire even when parts of production are down, a dashboard over the last hour loads in about a second, and losing a few seconds of samples is acceptable. Losing alerting is not.
- Functional: collect metrics with labels (
service,host,status) from every server; query them for dashboards by time range and labels, with sums, rates and percentiles; evaluate alert rules and notify on-call. - Non-functional: ingest 5 million samples a second; a sample is queryable within 30 seconds; a 1-hour dashboard panel loads in about 1 second; alerts fire within a minute once a rule's condition has held for its set duration, and keep working during partial outages; raw data for 7 days, rollups for 2 years.
- Out of scope: logs, traces, anomaly detection, the agent's install and upgrade tooling.
Estimate the scale
- Series: 100,000 × 500 = 50 million active time series.
- Samples: 50 million ÷ 10 s = 5 million samples a second, nearly constant all day. At about 20 bytes per sample on the wire, that is 100 MB/s into the system.
- Raw storage: a sample is a timestamp and a value, 16 bytes uncompressed. 5 × 106 × 86,400 = 432 billion samples a day, 6.9 TB a day uncompressed. Time-series compression (next section) typically gets a sample down to one or two bytes; assume 2: 864 GB a day.
- Queries: in an incident, say 2,000 dashboards open with 20 panels refreshing every 30 seconds: 2,000 × 20 ÷ 30 ≈ 1,300 queries a second. 100,000 alert rules every 30 seconds add a steady 3,300 a second.
How much storage do raw data for 7 days, 1-minute rollups for 30 days and 1-hour rollups for 2 years take together?
- 50 million series; 864 GB a day of raw samples after compression
- A rollup point stores 4 values (min, max, sum, count) at about 2 bytes each: 8 bytes
- 2 years ≈ 730 days
Show the working
- Raw: 864 GB × 7 ≈ 6 TB
- 1-minute: 50 × 106 × 1,440 × 8 B = 576 GB a day; × 30 ≈ 17 TB
- 1-hour: 50 × 106 × 24 × 8 B = 9.6 GB a day; × 730 ≈ 7 TB
- Total ≈ 6 + 17 + 7 = 30 TB
Estimate 30 TB (anything from 10 TB to 100 TB is a good answer)
Keeping raw samples for 2 years would be 864 GB × 730 ≈ 630 TB, about 20 times more.
Each active series costs about 1 KB of memory in the ingesters (its labels, index entries and the open compressed chunk). How much memory do active series need?
- 50 million active series
- About 1 KB of memory per active series
Show the working
- 50 × 106 × 1 KB = 50 GB, before replication
Estimate 50 GB (anything from 15 GB to 150 GB is a good answer)
Memory grows with the number of series, not samples. That is why one bad label can take the system down.
Write the write endpoint the agents call, the query endpoint dashboards call, and how an alert rule is defined.
Compare with a model answer
Agents send compressed batches of samples, many series per request. Dashboards send a query expression with a time range and a step. Rules are a query, a threshold and a duration the condition must hold, plus who to notify. The step parameter is the one to argue about: it sets how many points come back.
Define the API
POST /v1/write from agents, every 10 s, compressed
body: [ { "labels": {"__name__": "http_requests_total", "service": "checkout",
"host": "web-17", "status": "500"},
"samples": [[1763114700, 48213]] }, ... ]
204 No Content
429 Too Many Requests tenant over its sample-rate limit (agent backs off)
GET /v1/query_range?q=sum by (status) (rate(http_requests_total{service="checkout"}[1m]))
&start=...&end=...&step=60s
200 { "series": [ { "labels": {"status": "500"}, "points": [[t, v], ...] } ] }
422 Unprocessable query would touch more than 100,000 series
Alert rule
expr: error ratio for checkout > 5%
for: 5m the condition must hold this long before firing
notify: team-checkoutThe decision worth arguing is step. A panel 1,000 pixels wide cannot show more than about 1,000 points, so the query service picks the coarsest data that still fills it. Asking for a week at a 10-second step returns 60,480 points per series that the browser throws away. Making step part of the API lets the server pick a rollup tier and refuse queries that would touch too many series.
What identifies a series, how are samples stored, and how does a query find the series matching service="checkout"?
Compare with a model answer
A series is its metric name plus its full label set, hashed to an id. Samples are stored per series in compressed chunks covering a time range. An inverted index maps each label pair to the list of series ids that have it, and a query intersects those lists.
Model the data
series id = hash(metric name + sorted labels)
labels {__name__="http_requests_total", service="checkout", host="web-17", status="500"}
chunk series_id, start_time, end_time, compressed bytes (120 samples, 20 minutes)
only the open chunk stays in memory; full ones go to local disk
index "service=checkout" -> [series ids, sorted]
"status=500" -> [series ids, sorted]
query {service="checkout", status="500"} = intersect the two lists
blocks every 2 hours: chunks + index for that window, written as immutable
files to object storage; compaction merges blocks and builds rollupsThe access pattern is unusual. Writes are append-only, always at "now", and arrive for every series at once. Reads ask for a range of time for a set of series, and data is almost never updated. A general database would spend its effort on things this workload does not need. A time-series database (such as Prometheus, M3 or VictoriaMetrics) keeps each series' open chunk in memory and the rest of the last two hours on local disk, then writes immutable, compressed blocks that are cheap to keep in object storage.
Sketch the boxes from a server's counters to an engineer's dashboard and to a page on someone's phone. Which parts must keep working during an outage?
Compare with a model answer
An agent per host sends batches to a stateless ingest tier, which writes to a log; ingesters build series in memory and flush blocks. A query service reads them. An alert evaluator runs rules through the query service and hands firing alerts to a notifier, which pages through an outside provider. The alerting path must keep working, so it runs apart from what it watches.
High-level design
- series in batch
- 500
1The agent on web-17 reads the counters of the processes on its own host, 500 series, and sends one compressed batch to the ingest service. Collecting locally means a dead process shows up as a failed read, not as silence.
- series in batch
- 500
2Ingest checks the tenant's sample rate, then appends the batch to the log, partitioned by series id. The log is the durable copy until the store flushes a block.
- lag
- about 5 s
3The store's ingester for that partition appends each sample to the series' open chunk in memory, compressed as it goes. The sample is queryable now, a few seconds after it was measured.
- error ratio
- 7%
- held for
- 0 min
4Checkout's error ratio climbs past 5%. The alert evaluator runs the rule every 30 seconds through the query service; the first time it is true, the alert becomes pending.
- error ratio
- 7%
- held for
- 5 min
5Ten evaluations later the condition has held for 5 minutes, so the alert fires. A short spike would have cleared before this and paged nobody.
- pages sent
- 1
6Two evaluator replicas run the same rules, so the notifier receives the alert twice. It deduplicates by rule and labels, groups it with related alerts, and sends one page.
- points per series
- 10,080
7The on-call engineer opens the checkout dashboard for the last 7 days. The query service picks the 1-minute tier: 10,080 points per series instead of 60,480 raw ones.
- new series refused
- 5 million
8A deploy adds a
user_idlabel to a request counter. Within a minute checkout tries to create 5 million new series. The ingesters, which hold every active series, refuse new ones past checkout's limit and alert the team; existing series keep flowing.- rules with data
- 0
9A network failure cuts off the store. Rule queries now fail or come back empty, which the evaluator treats as a problem, not as all clear: it fires a "monitoring has no data" alert.
- pages sent
- 2
10Worse, the notifier itself goes down. The notifier normally sends the paging provider a heartbeat every minute; when it stops, the provider pages on its own. Silence is never mistaken for health.
Deep dives
Push or pull
Something has to move numbers from 100,000 hosts to the system. Either the system pulls (scrapes an endpoint on each target) or the targets push.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Central pull (scrape) | The system sets the pace and cannot be flooded; a failed scrape tells you the target is down. | Needs service discovery and network reach to every target; short jobs can finish between scrapes. | One cluster or data center you control. |
| App pushes directly | Works for short jobs, serverless functions and hosts behind NAT. | A quiet target and a dead one look the same; a buggy client can flood ingest. | Short-lived work with no stable address. |
| Local agent: pull locally, push batches Usual default | Local scraping detects dead processes; batched, compressed pushes cross the network once per host per 10 s; works anywhere. | An agent to run on every host; a dead agent must be detected by its own heartbeat. | Many hosts across regions and networks. Our choice. |
The agent buffers a few minutes of samples on local disk when ingest is slow or returns 429, and backs off with jitter so 100,000 agents do not retry in lockstep. Every agent also sends an up series; an alert on that series going absent is how you tell a dead host from a quiet one.
Storing time series
Compression works because neighbouring samples look alike. Timestamps arrive every 10 seconds: 1000, 1010, 1020, 1031. Store the first, then the difference of differences: 10 − 10 = 0, 11 − 10 = 1. Mostly zeros, which take a single bit. Values change slowly, so store each one XOR-ed with the previous one: an unchanged value becomes a single 0 bit, and a small change leaves only a few meaningful bits. That is how 16 bytes becomes one or two, and why samples are compressed per series in time order.
Downsampling and retention tiers. Old data is looked at in coarse views, so compaction rewrites it: raw for 7 days, 1-minute rollups for 30 days, 1-hour rollups for 2 years. Each rollup point keeps min, max, sum and count, not just an average, so a rollup can still answer "the highest CPU in that hour" and averages over any longer span stay correct (total sum ÷ total count). The tiers cut storage from about 630 TB to 30 TB. Percentiles cannot be rebuilt from those four values, so latency should be recorded as a histogram (counts per bucket), whose buckets roll up by adding.
The cardinality problem. A series is a unique combination of label values, so labels multiply. http_requests_total with 200 services × 50 endpoints × 10 status codes is 100,000 series, fine. Add user_id and every active user becomes new series: 1 million users each hitting 5 endpoint and status combinations is 5 million series, 5 GB of ingester memory at 1 KB each, from one line of code. Memory, index size and query cost all scale with series, so this is the usual way such systems fall over.
- Limit per tenant: cap active series per team and refuse new ones beyond it, as in the tracer. Existing series keep working.
- Make it visible: show each team its series count and the labels with the most values.
- Send unbounded values elsewhere: user ids, request ids and URLs with ids belong in logs and traces, not labels.
The query path
A dashboard query such as "error rate by status for checkout over the last hour" runs in four steps:
- Find series: intersect the index's sorted id lists for
__name__=http_requests_totalandservice=checkout. Refuse with 422 if more than 100,000 series match, before reading any data. - Pick a tier: use the coarsest tier that is still finer than the step. A 7-day panel 1,000 points wide has a step of about 10 minutes, so over 1,000 series it reads 10 million 1-minute points instead of 60 million raw ones.
- Fan out: series are sharded across ingesters by id, so the query service asks every shard holding matches for recent data, and object storage for older blocks, in parallel.
- Aggregate early: each shard computes partial sums by
statusand returns a few series, not thousands; the query service adds them up.
During an incident hundreds of engineers open the same dashboards. Align each query's start and end to multiples of the step and cache results by (query, step, time range): the older part of the range never changes, so a refresh only computes the newest few points.
Alerts that survive the outage
Evaluate each rule on a fixed schedule through the same query path, and require the condition to hold for a duration before firing, which filters out brief spikes. Run two evaluator replicas so one can die, and deduplicate in the notifier by rule and labels, so redundancy never means double pages. The observability lesson covers which conditions deserve a page.
The harder problem is shared fate. If monitoring runs on the same cluster, network and service discovery as production, the outage that needs an alert can take out the alerting too. Then the system is silent at the worst moment, and silence reads as "all fine".
- Separate failure domain: run monitoring on its own machines and network path, with no dependency on the services it watches (not their database, not their auth service, not their deploy system).
- No data is a signal: a rule whose series vanish fires a "no data" alert instead of quietly resolving.
- Watch the watcher: a small second monitoring stack in another region checks that the main one is scraping, storing and evaluating.
- Dead man's switch: an alert that is always firing sends a heartbeat to the outside paging provider every minute. If heartbeats stop, the provider pages, as in the last tracer step.
Bottlenecks and failures
At 10x, 50 million samples a second over 500 million series, ingest and the log scale out by adding nodes, and ingesters need 500 GB of memory for active series before replication. The index and query fan-out are next: split tenants onto separate shard groups so one team's huge query cannot slow everyone, and add more rollup tiers. Storage grows to about 300 TB, cheap in object storage.
| When this dies | What happens | What you do |
|---|---|---|
| An ingest node | Agents' requests to it fail. | Agents retry another node; their disk buffer covers the gap. |
| An ingester | Recent data for its series is briefly unreadable. | A replacement replays the log, which keeps a day, from its last flushed block; run 2 replicas per series for reads. |
| Object storage | Data older than about 2 hours is unreadable. | Recent dashboards and alerts still work from ingesters' memory. |
| The query service | Dashboards fail; alert evaluation fails. | Run it as a stateless fleet; give alerting its own query replicas so dashboard load cannot starve it. |
| An evaluator replica | Nothing visible. | The other replica keeps evaluating. |
| The notifier | No pages go out. | Run 2 or 3 that share dedup state. If all die, heartbeats stop and the outside provider pages anyway. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Servers write samples into a database table; a cron job checks thresholds and sends email. |
| Mid | Uses a time-series database, picks push or pull with a reason, estimates samples per second and storage, and adds downsampling. |
| Senior | Sizes by series as well as samples, plans for cardinality explosions, explains compression and rollups, designs the query path around step and fan-out, and keeps alerting alive when production and the monitor itself fail. |
Your monitoring runs in the same data center as production, and the data center loses its network. What happens?
As described, nothing pages, which is the worst outcome. So I'd run alerting outside production's failure domain, treat "no data" as an alert, and have the notifier send a heartbeat to an outside paging provider every minute. If the whole monitoring stack goes dark, the heartbeat stops and the provider pages on-call. The cost is a second small stack and an outside dependency.
Why not just run two copies of everything?
Two copies in the same failure domain fail together. What matters is that the thing that pages depends on nothing that the outage can take down.
Follow-up questions
How do you compute p99 latency across 1,000 servers?
Not by averaging each server's p99, which is meaningless. Each server records a histogram of request counts per latency bucket; add the buckets across servers, then read the 99th percentile from the summed histogram.
Can you lose samples?
A few seconds' worth, yes, if an agent's disk buffer fills. That is acceptable for metrics; the guarantee to protect is that alerts keep firing.
How do you support many teams fairly?
Per-tenant limits on samples per second, active series and query cost, separate shard groups for the largest tenants, and showing each team its usage.
How would you detect anomalies, not just thresholds?
Compare the current value with the same hour in previous weeks, or with a moving baseline, as an extra rule type. It runs on the same evaluator and query path.
What about very short-lived jobs?
A batch job that runs for 3 seconds can finish between collections. It pushes its final values to the local agent before exiting, and the agent sends them on with its next batch.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".