Design metrics monitoring and alerting
Every server, container and service emits numbers every few seconds: CPU, memory, request rates, error counts, latency percentiles. A monitoring system stores millions of these time series cheaply, draws them on dashboards in a blink, and wakes a person only when something is really wrong. The hard parts are the write volume, the storage format, cardinality and alerts that are neither silent nor noisy. It follows the interview framework phase by phase.
1. The prompt
"Design a metrics monitoring and alerting system" (think Prometheus, Datadog, Grafana Mimir, CloudWatch): collect metrics from a large fleet, let engineers query and graph them, and notify on-call engineers when rules are broken.
It is the most write-heavy design in the track: data arrives every second of every day, is almost never updated, and is mostly read for the last few hours. That shape drives a special-purpose storage engine.
2. Frame the problem
How big is the fleet?
About 100,000 hosts, ~500 series each.
50M active series: the write volume and memory budget come from this.
How often are samples taken?
Every 10 seconds.
5M samples a second, nonstop.
How long do we keep data?
Full resolution for two weeks, downsampled for a year.
Hot local storage plus cheap object storage.
Logs and traces too?
No, metrics only.
Numbers with labels: a time-series database, not a search index.
Multi-tenant?
Yes, many teams share it.
Per-tenant limits on series, or one team's mistake hurts everyone.
How fast must an alert fire?
Within a couple of minutes of a real problem.
Rule evaluation every 15–60 s with a short "for" duration.
3. What it must do
- Collect numeric metrics with labels (
http_requests_total{service="checkout", status="500"}). - Query and graph them: rates, sums by label, percentiles over any time range.
- Evaluate alert rules and notify the right people through pager, chat or email.
Not now:
- Logs and traces (same ideas, different storage).
- The dashboard UI itself.
4. How well it must do it
| Target | Why this number |
|---|---|
| Write-heavy: 5M samples a second, nonstop load shape | The fleet never stops reporting. |
| Fast recent queries: dashboards over the last few hours in under a second speed | Engineers debug incidents in real time. |
| Reliable alerting, even when what it monitors is down availability | The worst failure is a silent outage. |
| Cheap retention: a year, downsampled cost | Capacity planning and year-over-year comparisons. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Active series | 100,000 hosts × ~500 series each | 50M active time series → so shard by series |
| Ingest | 50M series ÷ one sample every 10 s | 5M samples a second → so sharded ingestion, append-only writes |
| Raw storage | 16 bytes a sample (8 B time + 8 B value) × 5M × 86,400 | ~6.9 TB a day uncompressed → so compression is essential |
| Compressed | Gorilla-style encoding: ~1.37 bytes a sample | ~590 GB a day → so hot data on local disk, older blocks in object storage |
| Year-long graph | One series × 3.15M raw points | → so downsample to 1-hour points (8,760) for long ranges |
6. The nouns
- Series: a metric name plus a set of labels.
- Sample: a (timestamp, value) pair.
- Block: compressed samples for a time range.
- Alert rule: a query, a condition, a duration and routing labels.
series id hash(name + sorted labels) → {__name__="http_latency_bucket", service="checkout", le="0.5"}
samples series id → [(t, v), (t, v), …] in-memory chunk + WAL, then immutable 2-hour blocks
label index label=value → posting list of series ids
rule name, expr, for, labels {severity, team}, annotations
7. The contract
GET /metrics (each target exposes current values; the collector scrapes it)
POST /api/v1/write (or push batches of samples)
GET /api/v1/query_range?query=histogram_quantile(0.99, sum by (le) (rate(http_latency_bucket[5m])))&start=…&end=…&step=60
rule: checkout_p99_high expr: p99_latency_ms > 400 for: 5m labels: severity=page, team=payments
Data flow: (1) collectors scrape or receive samples near the workloads → (2) forward batches to TSDB shards by series hash → (3) shards append to memory and a WAL, cut blocks every two hours → (4) old blocks go to object storage, compacted and downsampled → (5) the query service fans out to shards and storage → (6) a rule evaluator runs queries on a schedule and sends alerts to an alert manager.
8. First working version
Services write every measurement into one relational table (metric, labels, time, value); dashboards and a cron job query it.
8.1 Writing samples
8.2 Graphing
A dashboard runs a range query on the table with a B-tree index on (metric, time).
8.3 Alerting
A cron job runs each rule's query every minute and emails if it's true.
Parked for later (section 9 fixes each)
- 5M row inserts a second into one table, and every service needs database credentials.
- 16+ bytes a sample plus index overhead: tens of TB a day.
- Unbounded labels can create millions of series overnight.
- One-minute spikes page someone at 3 a.m.; a real outage sends 500 emails.
- If the database goes down, alerting goes down with it.
9. Make it hold up
9.1 Pull or push? write-heavy
| Approach | Why | |
|---|---|---|
| ✗ Bad | Every service writes each measurement straight into a central database | Millions of tiny writes, every service needs database credentials, and an overloaded database slows the services themselves. |
| ~ Good | Pull (Prometheus scrapes /metrics) or push (agents or OpenTelemetry collectors send batches) | Pull makes "is the target up?" free and keeps targets simple; push suits short-lived jobs, serverless and networks the collector can't reach. |
| ✓ Great | Collectors near the workloads (per cluster or region) that scrape or receive, batch and forward to a sharded central store | Local collection keeps working during network trouble, batching cuts overhead, and the central store scales by adding shards. |
9.2 The time-series database write-heavy · cheap
- Append-only, in memory first: recent samples for each series sit in an in-memory chunk, protected by a write-ahead log; every couple of hours they're cut into immutable, compressed blocks on disk (Prometheus works this way).
- Compression: timestamps arrive at regular intervals, so storing the difference of differences is usually zero; consecutive values are similar, so XOR-ing each with the previous leaves mostly zero bits. Facebook's Gorilla paper reports an average of 1.37 bytes per sample, about a 12× reduction.
- An inverted index on labels maps each label value to the series that have it, so
{service="checkout"}finds the right series quickly. - Sharding: hash the series id to pick a shard; replicate each shard (or run two collectors writing to two stores) so losing a node loses no data.
9.3 Long-term storage and downsampling cheap retention
Blocks are uploaded to object storage, compacted into larger blocks and downsampled (5-minute and 1-hour resolution) so a year-long graph reads few points. Thanos, Grafana Mimir and VictoriaMetrics add this to the Prometheus model. The query layer picks the coarsest resolution that still gives enough points for the graph's width.
9.4 Cardinality: the silent killer write-heavy · cost
Every distinct combination of label values is a separate series. Add a user_id label to a latency metric with 10M users and you create 10M series overnight, exhausting memory. Keep labels bounded (service, endpoint, status class, region), put per-user detail in logs or traces, and enforce per-tenant series limits at ingestion: reject or drop new series past the limit, and tell the team which metric grew.
9.5 Alerting that people trust reliable alerting
- Rule evaluation: every 15–60 s, evaluate each rule's query. A rule with
for: 5mmust be true for five minutes before it fires; until then it is pending. This filters one-off spikes. Try it in section 13. - Alert manager: deduplicates the same alert from replicas, groups related alerts into one notification, routes by labels (team, severity), and supports silences during maintenance.
- Alert on symptoms users feel (error rate, latency, SLO burn rate; Module 25), not on every cause (CPU at 80%).
9.6 Monitoring the monitor reliable alerting
Run rule evaluation and the alert manager in pairs, close to the data, and independent of the systems they watch. A separate, simple watchdog alerts if the monitoring system itself stops working: a "dead man's switch" alert that must always be firing, so its absence pages someone through an outside service.
9.7 The whole design after the deep dives
Samples from collection to an alert. Press Play or drag the timeline.
10. Interviewer follow-ups
How do you compute a p99 across 200 servers?
Not by averaging per-server p99s. Each server exports a histogram (counts per latency bucket); sum the buckets across servers, then compute the quantile from the combined histogram.
A dashboard query is slow.
Recording rules precompute expensive expressions every minute into new series; the dashboard reads those. Also use a coarser step or resolution for long ranges.
One team creates 5M series by mistake.
Per-tenant series limits reject the excess at ingestion; the team gets an alert naming the metric and label. Other tenants are unaffected.
Global view across 20 regions.
Keep collection and alerting regional; a global query layer fans out to regional stores (or a central store receives copies) for cross-region dashboards.
A host stops sending metrics.
With pull, the scrape fails and up == 0 is a metric you can alert on. With push, alert on "no samples for N minutes" (absent()).
Late or out-of-order samples.
Accept within a small window into the in-memory chunk; reject older ones, or allow a configurable out-of-order window at the cost of more memory.
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| TSDB shard down | Gaps in some graphs | Shard health | Replica; WAL replay on restart |
| Collector can't reach store | Delayed data | Remote-write queue size | Local buffer; regional alerting keeps working |
| Cardinality explosion | Slow queries, memory alarms | Series count per tenant | Per-tenant limits |
| Alert manager down | No pages | Dead man's switch | Pair of alert managers; outside watchdog |
| Alert storm | Pager flood | Notification rate | Grouping, inhibition rules |
12. Wrap-up and the clock
Trade-offs we made:
- Resolution vs cost: 10-second samples catch short blips but cost six times more than one-minute samples; downsample old data aggressively.
- Local vs global view: one store per cluster is simple and robust; a global query layer gives one view at the cost of a bigger system to run.
- Fast vs quiet alerts: short
fordurations detect faster and page more often for nothing; burn-rate alerts over two windows balance both. - Bounded labels over unlimited detail.
What we'd watch:
- Ingest rate and dropped samples.
- Active series per tenant.
- Query p99.
- Rule evaluation delay.
- Pages per on-call shift.
At 10×:
- More shards.
- Stricter cardinality limits.
- More recording rules.
- Regional stores with a global query layer.
A 45-minute interview
| 0–4 | Frame the problem (fleet, interval, retention) |
| 4–9 | Must do, how well, numbers (5M samples/s, 590 GB/day) |
| 9–12 | Nouns, contract, data flow |
| 12–18 | First working version, parked list |
| 18–40 | Deep dives: collection (9.1), the TSDB (9.2), cardinality (9.4), alerting (9.5) |
| 40–45 | Follow-ups and wrap-up |
A 60-minute interview
| 0–5 | Frame the problem |
| 5–11 | Must do, how well, numbers |
| 11–15 | Nouns, contract, data flow |
| 15–23 | First working version |
| 23–50 | Deep dives 9.1–9.6 and the final picture |
| 50–60 | Follow-ups: global p99, slow dashboards, global view; wrap-up |
| Level | What a strong answer shows |
|---|---|
| Mid-level | Agents send metrics to a collector and a time-series database; dashboards query it; rules send alerts. |
| Senior | Pull vs push, write-volume estimate, append-only chunks and compression, label index, cardinality limits, downsampling, for durations and an alert manager that groups and routes. |
| Staff+ | Multi-tenant sharding and limits, global query over regional stores, meta-monitoring, SLO-based alerting policy, cost per series, and making the monitoring system survive the outages it reports. |
Common mistakes:
- A general-purpose database for samples.
- Unbounded labels.
- Averaging percentiles.
- Alerting on causes, not symptoms.
- Monitoring that shares fate with what it monitors.
13. Try it, check yourself, read more
Six hours of checkout p99 latency, one point a minute. A real incident starts at 3:50 and lasts half an hour; the rest has noise and a few one-minute spikes. Tune the rule so it pages for the incident quickly but not for the spikes.
Why is adding a user_id label to a latency metric dangerous?
Why does time-series data compress so well?
What does for: 5m on an alert rule do?
How do you get the fleet-wide p99 latency?
| Resource | Why |
|---|---|
| Gorilla: a fast, scalable, in-memory time series database (VLDB 2015) | The compression scheme most time-series databases use. |
| Alerting rules (Prometheus docs) | Pending and firing states, for and keep_firing_for. |
| Monitoring distributed systems (Google SRE book) | Symptoms vs causes and the four golden signals. |