all_lessons / system_design / cases / C24 C24 / C44

Design metrics monitoring and alerting

A metrics system is a write-heavy time-series database wearing a human contract. Its real adversary isn't request volume — it's cardinality: the number of distinct series you're forced to keep alive, which one careless label can blow up by a thousand-fold overnight.

Source: Vol. 2 Ch. 5, archive Case drill Trade-off first
First principle — what makes this problem forcing
Most storage systems scale with the volume of data you write. A metrics system scales with the number of distinct time series it must track — and the cost is dominated not by samples but by the index that maps (metric, labels) → series. Every unique label-value combination is a new series with its own index entry, its own in-memory chunk, its own compaction lineage. The forcing constraint is therefore cardinality control: a single high-cardinality label (a user_id, a request_id, an unbounded url) multiplies your series count by its number of distinct values, and that multiplication lands on the most expensive part of the system. Everything else — ingestion, tiered storage, alerting — is downstream of getting cardinality right.

0. The hinge

The hinge is the sentence that turns a generic "build monitoring" prompt into a forced architecture. Here it is: storage and query cost are driven by series count, not sample count, and series count is set by label cardinality — so the design must bound cardinality at ingestion or it dies. A candidate who leads with "we'll shard the writes and use a TSDB" has skipped the hinge; the interviewer is waiting to hear you reason about the cardinality bomb before you draw boxes.

1. Clarify the contract

Treat the prompt as a product contract before a component diagram. The system must:

And explicitly what it need not do: it is not a system of record. Monitoring data is lossy-tolerant — dropping 0.1% of samples during an incident is acceptable; an unavailable monitoring system during that same incident is not. It is also not a tracing or logging system; high-cardinality per-request detail belongs in traces/logs (different storage model), and conflating the two is exactly how the cardinality bomb gets planted. This relaxed durability is what lets us favour append-optimised, eventually-consistent storage over a transactional database.

2. Put numbers on it — the cardinality bomb

The whole case lives or dies on one calculation, so do it explicitly. Cardinality is multiplicative across labels:

series = ∏ (distinct values of each label) , summed over each metric name

Consider a deceptively innocent metric, http_requests_total, with a per-user label:

requests{user_id, endpoint} user_id : 10,000,000 distinct values (one per user) endpoint : 20 distinct values ──────────────────────────────────────────────── series = 10,000,000 × 20 = 200,000,000 distinct series

Two hundred million series from one metric. Now price the churn. Each series emits a sample on every scrape; at one scrape every 15 s and ~8 bytes per stored sample (timestamp delta + compressed value, roughly Gorilla-style):

samples/day = 200M series × (86,400 s / 15 s) = 200M × 5,760 ≈ 1.15 × 1012 samples/day

raw sample bytes = 1.15 × 1012 × 8 B ≈ 9.2 TB/day (before compression)

But the samples are the cheap part — Gorilla compression squeezes them to ~1–2 bytes amortised. The killer is the index. Every one of the 200M series needs an inverted-index posting (label → series-id) plus an in-memory head chunk. Index overhead is on the order of hundreds of bytes to a kilobyte per series; even at a conservative ~500 B/series that is 200M × 500 B ≈ 100 GB of index alone, churning every time series appear/disappear (a process called churn, e.g. when users are active for only part of the day). Call it ~100 GB/day of index churn just to track which series exist — independent of how many samples each holds. That index has to fit largely in RAM to keep queries fast, so this metric alone would demand a multi-hundred-GB memory footprint per shard.

Now bound the label instead. Replace user_id with a tier label (free / pro / enterprise):

requests{tier, endpoint} tier : 3 distinct values endpoint : 20 distinct values ──────────────────────────────────────────────── series = 3 × 20 = 60 distinct series (vs 200,000,000)
Series (user_id label)
200 M
Series (tier label)
60
Reduction
~3.3 M×
Index churn, user_id label
~100 GB/day

Same observability question — "how is request volume split across endpoints and customer tiers?" — answered by 60 series instead of 200 million. The information you actually act on is almost always aggregate; per-user breakdowns belong in logs or traces, queried on demand, not in a metric that must materialise every combination forever. This single contrast is the entire lesson. Whenever a label's distinct-value count is unbounded or grows with traffic (IDs, emails, URLs, full error messages), it does not belong in a metric.

3. Define the surface area

Keep the API small and aggregate-shaped; it must not leak the shard or rollup layout.

API / operationWhy it exists
write(samples[])Batched ingest of (metric, labels, ts, value); the hot path, append-only.
query(metric, label_matchers, range, step)Range query for dashboards; step lets the engine pick a rollup tier.
instant_query(expr, at)Single-point evaluation — what alert rules and "now" panels use.
create_alert_rule(expr, for, severity, route)Registers a rule the evaluator runs on a fixed cadence.

4. Model the data

The data model is the first architecture. A sample is tiny; its identity is everything.

metric namelabel set → fingerprintsample (ts, value)rollup (min/max/sum/count/pXX)alert state (pending/firing)

A series is identified by the hash of its full label set — its fingerprint. The fingerprint is the partition key (more on this below) and the index key. Samples for a series are stored as a compressed, time-ordered run; the index maps each label name=value to the set of fingerprints carrying it, so a query like requests{tier="pro"} intersects posting lists to find matching series, then scans their sample runs over the requested time range.

5. Linearized design

Walk an event in the order it actually moves, and the first bottleneck — cardinality — surfaces on its own.

  1. Collect. Agents/scrapers pull from targets (stable fleets) or a push gateway accepts pushes (short-lived jobs). Pull gives the system control over scrape cadence and a free up/down signal; push suits batch jobs that die before any scrape.
  2. Validate & relabel. At ingestion, enforce a per-tenant series budget and drop or rewrite high-cardinality labels (request_id, raw url). This is the cardinality firewall — the cheapest place to stop the bomb.
  3. Shard by fingerprint. Hash the label set to route every sample for a series to the same node, so a series is contiguous and queryable without cross-node scatter.
  4. Hot write-store. Recent samples land in an append-optimised, in-memory head block, periodically flushed to immutable on-disk blocks. Writes are sequential; reads of "now" are cheap.
  5. Downsample into rollup tiers. A background job rolls raw → 5 m → 1 h aggregates, each with its own retention. Long-range dashboard queries read the coarse tier and touch a fraction of the data.
  6. Evaluate alerts. A scheduler runs each rule as an instant_query on aligned windows, applies a for: duration, and persists alert state before routing notifications.
INGEST FIREWALL SHARD HOT WRITE-STORE ROLLUP TIERS (older) ────── ──────── ───── ─────────────── ──────────────────── scrapers ─┐ agents ─┼─▶ relabel / drop ─▶ fingerprint(labels) ─┬─▶ shard A: head→blocks ─┬─▶ 5m (retain 30d) push gw ─┘ cardinality cap % N shards ├─▶ shard B: head→blocks ─┼─▶ 1h (retain 2y) └─▶ shard C: head→blocks ─┘ │ (raw, retain 15d, in RAM + disk) ▼ query engine ─▶ dashboards alert evaluator ─▶ pager

6. Deep dives

6.1 Cardinality is the dominant cost driver

Section 2 priced it; here is why it dominates. A TSDB's cost has two terms: the per-sample cost (compressed, tiny — Gorilla gets ~1.37 bytes/sample) and the per-series cost (index posting, head chunk, label strings — hundreds of bytes, mostly RAM-resident). Sample cost scales with scrape frequency; series cost scales with cardinality. Because the per-series constant is two-to-three orders of magnitude larger and lives in memory, cardinality wins the cost equation almost always.

The trap is that cardinality is multiplicative and silent. Adding a single label that looks harmless — request_id on an error counter — turns N series into N × (requests) series, because every request is a new value. There is no error, no rejection by default; the series count just climbs, the index outgrows RAM, head-block memory balloons, and one node OOMs. This is why the firewall lives at ingestion (DDIA Ch. 6, partition by series fingerprint, also gives us the natural enforcement point): you cap series-per-tenant and reject or relabel before the write ever touches storage, because once a bad label is written, removing it still leaves the index churn already paid.

6.2 Time-series storage is append-heavy and time-ordered — which is LSM-friendly

Samples arrive in roughly monotonic timestamp order and are essentially never updated in place. That access pattern is the textbook fit for a log-structured merge tree (DDIA Ch. 3): buffer writes in an in-memory structure (the "head"), flush to immutable sorted blocks, and compact/merge blocks in the background. There are no random in-place updates, so there is no B-tree write amplification from page splits; sequential append throughput is the whole game.

The model is also columnar within a series (Ch. 3's column-oriented storage): a series is a column of (Δt, value) pairs, delta-of-delta encoded for timestamps and XOR-encoded for values, so a CPU-cheap, highly compressible run. Old immutable blocks are exactly what the downsampler reads to build rollup tiers, and exactly what we can age out by simply deleting whole blocks past retention — no tombstones, no vacuum. The relaxed durability from §1 is what permits this: we can lose the un-flushed head on crash (replayed from a short write-ahead log, or just tolerated) rather than pay for synchronous fsync per sample.

one series over time, columnar + delta-encoded ts: 1000 1015 1030 1045 1060 ... (stored as Δ=15, dod=0 → ~bits) val: 42.0 42.1 41.9 42.0 43.7 ... (XOR vs previous → mostly 0 bits) └──── compressed run, append-only, scanned in time order ────┘ head (RAM) ──flush──▶ block_t0 ──compact──▶ block_t0..t2 ──downsample──▶ 5m rollup

6.3 Alert on symptoms (SLO burn), not causes

The third deep dive is about the human contract. There are two ways to write an alert rule, and the choice decides whether the pager is trusted or ignored.

The disciplined form is multi-window burn-rate alerting on an error budget. If your SLO is 99.9% (budget = 0.1% errors over 30 days), define burn rate = observed error rate ÷ budget rate. A fast-burn rule pages when you're burning the monthly budget in hours (e.g. burn > 14.4 over a 1 h and 5 m window — the short window confirms it's current, the long window suppresses single-spike noise); a slow-burn rule opens a ticket when you'd exhaust it in days. The windowed evaluation here is DDIA Ch. 11 stream processing: the evaluator runs each rule over a sliding time window on a fixed cadence, which is precisely a windowed aggregation over the sample stream, and the for: duration plus hysteresis is how we suppress flapping at the window edge.

7. Trade-offs

ChoiceBuysCostsChoose when
Pull (scrape) vs pushCentral control of cadence; free target-up signal.Hard for short-lived jobs that die before a scrape.Stable service fleets; use a push gateway only for batch jobs.
High resolution vs long retentionDebug detail on recent windows.Storage and RAM cost grows with resolution × retention.Keep raw for days, downsample for months/years.
Flexible labels vs strict schemaAd-hoc debugging dimensions.Cardinality explosions — the §2 bomb.Flexible only with a hard ingestion-time series budget.
Symptom (SLO) alerts vs cause alertsLow noise; every page means real pain.Requires SLO thinking and burn-rate math up front.Production paging — always. Cause metrics are for dashboards.

The sharpest one is flexible labels vs strict schema. Teams love arbitrary labels for debugging, and a strict schema feels bureaucratic — until someone labels by request_id and the cluster OOMs. The senior resolution is not "ban labels" but "flexible labels behind a budget": allow any label, but enforce a per-metric, per-tenant series cap at ingestion that rejects new series once exceeded and pages the owning team. You get debugging freedom with a hard ceiling on the blast radius — the same admission-control philosophy as foundation lesson 15, applied to series instead of requests.

8. Failure modes

FailureMitigation
High-cardinality metric storm (someone adds request_id)Per-tenant series budget at ingestion; reject/relabel over the cap; alert the owner. Limit blast radius to one tenant's quota.
Alert flapping at the thresholdfor:-duration windows + hysteresis (fire above X, resolve below X−margin); multi-window burn-rate confirmation.
Ingestion lag during a spikeTrack pipeline staleness as its own metric; suppress or delay rule evaluation on stale data so you don't page on a gap, not a fault.
Dashboard too slow on long rangesServe from rollup tiers chosen by query step; cap series-per-query; reject unbounded matchers.
Hot shard from one popular fingerprint setPartition by fingerprint (07); for skew, sub-shard a hot tenant or split its label space.

9. What to say in the interview

Senior interview Q&A
  1. Dashboards slowed to a crawl and storage tripled overnight with no traffic change — what happened? (senior answer) Almost certainly someone deployed a new high-cardinality label — a request_id, a raw url, a per-user ID — onto an existing metric. Traffic is flat but series count exploded, so the index outgrew RAM (slow queries) and head/block storage ballooned (3× storage). I'd confirm with a "top series by metric/label" query, identify the offending label, relabel-drop it at ingestion, and add a per-tenant series budget so it can't recur. The tell is "storage up, traffic flat" — that's a cardinality event, not a load event.
  2. Why does cost track series count rather than sample volume? (senior answer) Samples compress to ~1–2 bytes (Gorilla delta-of-delta + XOR), but each series carries a fixed index posting, label strings, and an in-memory head chunk on the order of hundreds of bytes, mostly RAM-resident. The per-series constant dwarfs the per-sample one and lives in memory, so cardinality dominates. 200M series from one user_id label is ~100 GB/day of index churn before a single sample's storage.
  3. Why is a TSDB built on an LSM-tree rather than a B-tree? (senior answer) Writes are append-only and arrive in near-timestamp order; there are no in-place updates. An LSM (DDIA Ch. 3) buffers in a memory head, flushes immutable sorted blocks, and compacts in the background — pure sequential IO, no B-tree page-split write amplification. Retention is just deleting whole expired blocks, no vacuum.
  4. Pull or push for collection? (senior answer) Pull (scrape) for stable fleets — the monitoring system controls cadence, gets a free up/down signal, and service discovery drives targets. Push via a gateway only for short-lived batch jobs that die before a scrape could reach them. Most production systems are pull-first with a push escape hatch.
  5. How do you alert without drowning humans in noise? (senior answer) Alert on symptoms (SLO/error-budget burn), not causes. Page only on user-visible pain — error rate or p99 against the budget — using multi-window burn-rate rules (a fast 5 m/1 h window to page, a slow window to ticket). Cause metrics like CPU live on dashboards, not the pager. Cause alerts train people to ignore the pager, which is worse than no alert.
  6. An alert is flapping on and off every minute — fix it without missing real events. (senior answer) Add a for: duration so the condition must hold continuously before firing, and hysteresis — fire above X but only resolve below X − margin — so it doesn't oscillate around the threshold. Backed by multi-window confirmation so a single scrape spike can't page. This is windowed stream evaluation (DDIA Ch. 11) with edge-suppression.
  7. How do you keep long-range dashboard queries fast? (senior answer) Downsampled rollup tiers (raw → 5 m → 1 h), each with its own retention; the query engine picks the coarsest tier whose resolution satisfies the requested step, so a 90-day chart touches hourly rollups, not raw. Store min/max/sum/count and chosen percentiles per rollup bucket — never just the average, or you'll hide spikes.
  8. Where does ingestion partition, and what's the risk? (senior answer) By series fingerprint — the hash of the full label set — so all samples for a series are contiguous on one node (DDIA Ch. 6, foundation lesson 07). The risk is a hot shard if one tenant or label set dominates; mitigate by sub-sharding that tenant's label space. Fingerprint partitioning is also the natural enforcement point for the per-tenant series budget.

Related foundation lessons & cases

This case implements foundation lesson 16 · Observability — that lesson defines metrics/SLOs as a concept; here we build the system that stores and evaluates them. The ingestion pipeline is built on the partitioned append-log of C23 (distributed message queue) — samples flow in over the same kind of append-only, fingerprint-partitioned log. Sharding by series fingerprint is lesson 07 · Partitioning's hot-shard problem applied to time series. And the alerting half exists to defend the latency/error SLOs introduced in foundation lesson 02 · Latency & throughput — burn-rate alerts are how you notice the utilisation cliff before users do.

16 · Observability C23 · Message queue 07 · Partitioning 02 · Latency & throughput 13 · Fault tolerance
Takeaway
A metrics system is a write-heavy, append-only time-series database whose cost is governed by cardinality, not sample volume: requests{user_id} at 10M users × 20 endpoints is 200M series and ~100 GB/day of index churn, while the same insight via {tier, endpoint} is 60 series — so bound cardinality at ingestion or the index outgrows RAM and the cluster falls over. The time-ordered, never-updated access pattern makes an LSM the right storage engine (DDIA Ch. 3), fingerprint partitioning the right shard key (Ch. 6), and windowed evaluation the right alerting model (Ch. 11). The human half is just as load-bearing: page on SLO-burn symptoms, never on internal causes, or you train people to ignore the one signal that matters.