all_lessons / data_intensive_systems / 34 · case: metrics lesson 35 / 35 · ~18 min

Part 12 · Applied & interviews

Interview Case: Metrics / Observability Dashboard

The previous case (lesson 33) built a model registry — a small, append-only system of record where every byte matters, the "promote to prod" pointer is linearizable, and losing a write is unacceptable. This case turns the dial all the way to the opposite pole. A metrics and observability backend ingests millions of time-series at once, is overwhelmingly write-heavy, and — crucially — tolerates approximation and loss: a dropped sample or a p99 computed from a sketch is fine if the dashboard stays fresh. The same toolbox (partitioning, columnar storage, the durable log, materialized aggregates) builds it, but the invariant flips from integrity to timeliness. That contrast is the heart of the interview.

DDIA source
Synthesis over Martin Kleppmann, Designing Data-Intensive Applications — column-oriented storage and materialized aggregates / data cubes (Ch.3), partitioning by key (Ch.6), the partitioned event log and stream processing (Ch.11), and the derived-data close on timeliness vs integrity (Ch.12). Original interview framing; the time-series engine here is modeled on the design space of Prometheus, M3DB, VictoriaMetrics, InfluxDB, and Monarch — not any one product.
Linear position
Prerequisite: partitioning and hot keys (L11), LSM + columnar storage and materialized aggregates (L04), the partitioned log and stream ingest (L19), and timeliness-vs-integrity (L20).
New capability: design a write-heavy, high-cardinality time-series store end to end — partition by series id, ingest through a log, store columnar and time-partitioned, roll up to cheap downsampled tiers, age data from hot to cold, and answer dashboard queries (including p99 over millions of series) with approximate aggregates inside a query-latency budget.
The plan
Six moves, following the shared case template. (1) The prompt, requirements, and the clarifying questions a strong candidate opens with. (2) Back-of-envelope sizing — series count, ingest points/sec, raw vs rolled-up storage, retention, query p99 budget. (3) The design walk — ingest fan-in through a log, partition by series id, columnar time-partitioned storage, rollup/downsampling tiers, hot/cold separation, and approximate aggregates — cross-linking each mechanism to its home lesson. (4) A concrete failure timeline (an ingest fan-in overload) and its mitigation. (5) A four-bucket answer rubric. (6) Takeaway plus interview prompts with answers.

1 · The prompt and what to ask first

The prompt. "Design the storage and query backend for a metrics and observability platform. Services across the fleet emit time-series — CPU, request latency, error counts, queue depth, and thousands of custom application metrics — tagged with labels like host, region, service, endpoint, and status code. Engineers open dashboards that plot these over the last hour, the last day, or the last quarter, and alerting rules evaluate them continuously. Make ingest keep up at fleet scale and make dashboards load fast." This is a textbook write-heavy, read-light, append-mostly workload — almost the inverse of the model registry.

Functional requirements. Ingest tagged samples (a metric name plus a set of label key-value pairs plus a timestamp and a float value) from millions of sources; store them durably enough; serve range queries ("series X over the last 6 hours") and aggregations ("p99 latency by region over the last day", "sum of error rate across all hosts") fast; support retention with cheap long-term history.

Non-functional requirements. Ingest must absorb a write-heavy fan-in without dropping more than a tiny, bounded fraction even under spikes. Dashboard query p99 must sit inside a few hundred milliseconds for recent windows. Old data must get cheap — a quarter of history cannot cost the same per point as the last hour. And the system explicitly tolerates lossy and approximate answers: a missing scrape or a p99 from a sketch is acceptable; freshness and availability beat exactness.

Clarifying questions a strong candidate asks

2 · Back-of-envelope sizing

Pin numbers down first; they drive every later choice. Assume a fleet emitting 10,000,000 active series (10 million distinct metric+label combinations), each scraped every 15 s.

Worked sizing
Ingest rate. 10,000,000 series ÷ 15 s = 666,667 points/sec, round to ~700,000 points/sec sustained. A single point is a (series-id, timestamp, value) tuple. This is the write-heavy fan-in: nearly a million writes a second, every second, forever.

Raw storage, naive. A naive row is ~16 bytes (8-byte timestamp + 8-byte float) plus per-row series identification — call it 50 bytes uncompressed. 700,000 pts/s × 50 B × 86,400 s/day = ~3 TB/day. Over 7 days of raw that is ~21 TB — before replication. Unacceptable to keep at that cost for long.

Raw storage, compressed columnar. Time-series compress brilliantly: delta-of-delta on timestamps (the step is nearly constant) and XOR-of-floats (Gorilla-style) on values routinely hit ~1.3-2 bytes per point. At 1.5 B/point: 700,000 × 1.5 × 86,400 = ~91 GB/day of raw. Seven days raw ≈ ~635 GB, times a replication factor of 3 ≈ ~1.9 TB. Columnar + delta encoding bought a ~33× reduction over the naive row. (This is the L04 columnar win, applied to time.)

Rolled-up storage. Downsample raw 15 s to 1-min (4× fewer points) for 30 days, and to 1-hour (240× fewer points) for 1 year. 1-min tier: 91 GB/day ÷ 4 × 30 ≈ ~683 GB. 1-hour tier: 91 GB/day ÷ 240 × 365 ≈ ~138 GB. A full year of history thus costs less than a single week of raw — old data got cheap, which was a hard requirement.

Query p99 budget. "p99 latency by region, last 6 hours" over the raw tier would touch 6 h × 3,600 ÷ 15 = 1,440 points per series × however many series match the label filter. If the filter matches 50,000 series that is 72,000,000 points scanned — too slow for a 300 ms budget. Hitting the 1-min rollup instead drops it to 360 pts/series × 50,000 = 18,000,000, and a 1-hour rollup with a pre-merged histogram drops it to ~6 pts/series × 50,000 = 300,000 — comfortably inside budget. The rollup tier is not just a storage saving; it is what makes the read budget achievable.

The sizing already dictates the architecture: 700k writes/sec forces an ingest buffer and partitioning; 3 TB/day naive forces columnar compression; the 300 ms read budget over millions of series forces rollups and pre-aggregated histograms.

3 · Design walk — build it from the track's mechanisms

Read the pipeline left to right: a write-heavy fan-in lands in a durable log, is partitioned by series id, is written columnar and time-partitioned, is continuously rolled up into cheaper tiers, and ages from hot local storage to cold object storage. Each stage maps to a lesson.

INGEST (write-heavy fan-in, ~700k pts/s) millions of agents ─┐ host / service ─────┼──► [ load balancer ] ──► [ ingest tier ] scrapers ───────────┘ │ parse, label, assign series-id ▼ ┌───────────────────────────────────────┐ │ PARTITIONED DURABLE LOG (L19) │ │ topic partitioned by hash(series-id) │ ← buffer absorbs spikes, │ P0 P1 P2 ... Pn │ decouples ingest from storage └───────────────────────────────────────┘ │ consumers per partition ▼ STORAGE (partition by series-id — L11) ┌─────────────────────────────────────────────────────────────────────┐ │ shard 0 shard 1 shard 2 ... shard n │ │ ┌───────────┐ ┌───────────┐ │ │ │ HOT tier │ │ HOT tier │ columnar, time-partitioned blocks │ │ │ recent ~2h│ │ recent ~2h│ (L04): per-series columns, │ │ │ in-memory │ │ in-memory │ delta-of-delta ts + XOR floats │ │ │ + WAL │ │ + WAL │ │ │ └─────┬─────┘ └─────┬─────┘ │ │ │ flush + compact│ │ │ ▼ ▼ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ ROLLUP / DOWNSAMPLE (materialized aggregates — L04) │ │ │ │ raw 15s (7d) ─► 1-min (30d) ─► 1-hour (1y) │ │ │ │ each step stores min/max/sum/count + a histogram sketch │ │ │ └────────────────────────────────────────────────────────────┘ │ └───────────────────────────────┬─────────────────────────────────────┘ age out ↓ (recent = hot/local SSD, old = cold object store) ┌──────────────────────────────┐ │ COLD tier: object storage │ cheap, slower, immutable blocks └──────────────────────────────┘ ▲ QUERY (read-light, p99 ≤ ~300 ms) ──┘ dashboard / alert ─► [ query layer ] ─► pick the COARSEST tier that satisfies the window+step ─► fan out to matching shards ─► merge sketches ─► return p99/sum/rate

3.1 Ingest: a log absorbs the fan-in

Millions of agents push samples into a stateless ingest tier that parses each sample, resolves its label set to a stable series id (a hash of metric name + sorted labels), and appends to a partitioned durable log (the L19 event log: an ordered, append-only, replicated log split into partitions). The log is the shock absorber — it decouples a bursty 700k-points/sec write rate from the storage tier's flush cadence, and on a storage-node restart the consumer simply replays from its last offset. We are explicitly not running a transaction or a quorum write per sample; that would be system-of-record machinery this workload does not need. A bounded buffer that may shed load under extreme spikes is the right call here — timeliness over integrity (L20).

3.2 Partition by series id

The log topic and the storage shards are both partitioned by hash(series-id) (L11): each series lives on exactly one shard, so all writes and all reads for that series route to one place — a single-hop point lookup, no cross-shard coordination on the write path. Hashing the series id spreads the ~700k writes/sec evenly and tames the timestamp hot-end trap (range-partitioning by time would send every current write to the newest partition — the classic L11 hot end). The residual risk is a single hot series: one extremely high-frequency metric whose lookups all land on one shard. The L11 fix transfers directly — front it with a cache, or split that one series across sub-keys. A query that filters by label but not by an exact series (most dashboard queries) is a deliberate scatter-gather: it fans out to all shards holding matching series and merges, which is fine for a read-light workload but is why we keep shard count and per-shard scan cost low via rollups.

3.3 Columnar, time-partitioned storage

Within a shard, data is written columnar and time-partitioned (L04). Recent writes accumulate in an in-memory head block backed by a WAL (the L03 write-ahead log for crash recovery), then flush to immutable time-bounded blocks (e.g. 2-hour blocks) that LSM-style compaction later merges. Storing each series' timestamps and values as separate compressed columns is what unlocks the encodings from the sizing: delta-of-delta on the near-constant 15 s timestamp step and XOR-of-floats on slowly-changing values, together giving ~1.5 bytes/point — the 33× reduction over naive rows. Columnar also means a query reads only the columns and time-ranges it needs, not whole rows.

3.4 Rollup / downsampling into materialized aggregates

A background job continuously downsamples raw blocks into coarser tiers — raw 15 s → 1-min → 1-hour — and at each step stores not just an average but a materialized aggregate (the L04 data-cube idea applied to time): min, max, sum, count, and a histogram sketch per bucket. These are precomputed answers, so a "last quarter, hourly" query reads a few hundred pre-merged points instead of scanning millions of raw ones. Crucially, you cannot recover a p99 by averaging means — so the rollup must carry an aggregable representation of the distribution, which leads to the next piece.

3.5 Approximate aggregates for percentiles over cardinality

Computing an exact p99 latency "across all hosts in a region" would require collecting every raw sample from tens of thousands of series and sorting — impossible inside 300 ms. Instead the system stores approximate, mergeable sketches:

t-digest / DDSketch
Quantile sketches: a few KB per series-bucket that answer "p50, p90, p99" within bounded relative error, and — the key property — two sketches merge into a sketch for the union. So a region-wide p99 is a merge of per-series sketches, not a global sort.
HdrHistogram / fixed-bucket
Pre-bucketed latency histograms (counts per latency band). Summing counts across series is exact addition; the percentile is then read off the merged buckets. Bucket boundaries bound the error.
HyperLogLog (HLL)
For "how many distinct hosts/users emitted this", an HLL sketch estimates cardinality in ~1.5 KB with a few percent error, mergeable across shards — vastly cheaper than tracking exact distinct sets at this scale.

Mergeability is the whole point: each sketch is stored per series-per-bucket in the rollup tiers, and a scatter-gather query simply merges the sketches returned by each shard. This is the L20 timeliness-vs-integrity trade made concrete — we accept a bounded approximation in exchange for answering p99 over ten million series inside a tight budget.

3.6 Hot / cold tiering

Recent data (last few hours, raw resolution) lives hot on local SSD or in memory where dashboards and alerts hammer it; older blocks age out to cold object storage (immutable, cheap, slower) once they have been rolled up. The query layer transparently spans both. This mirrors the LSM tiering instinct from L04 and keeps the expensive fast tier small while history stays affordable.

Contrast with a system of record

The model registry (lesson 33) is a system of record: every write must survive, the promote pointer is linearizable (L16), and integrity is the non-negotiable invariant. This metrics store flips every one of those. It tolerates dropped samples, computes percentiles from sketches, never needs a linearizable write, and treats timeliness and availability as the invariants. Same mechanisms (log, partitioning, columnar storage, materialized aggregates), opposite priorities — naming that flip out loud is the senior signal the interviewer is listening for (L20).

4 · Failure timeline — ingest fan-in overload

The defining failure of a write-heavy fan-in is a coordinated write spike that outruns the storage tier — for example, a fleet-wide incident causes every service to emit a burst of error and latency metrics at once, and a new noisy label (a per-request id mistakenly used as a label) explodes cardinality.

t0 Normal: ~700k pts/s, log lag ~0, query p99 ~120 ms. t1 Incident: every service spikes error/latency metrics; a deploy adds a high-cardinality label (request_id) → active series jumps 10M → 60M. t2 Ingest rate hits ~3M pts/s. The partitioned log absorbs the burst (lag grows but writes still ACK) — the buffer is doing its job. t3 Storage consumers fall behind: flush + compaction can't keep up; hot-tier memory fills with new series; head blocks grow; WAL replay time balloons. t4 Dashboards for the incident (the moment engineers MOST need them) slow: query p99 climbs to seconds as scatter-gather hits oversized head blocks. t5 MITIGATION kicks in (see below): cardinality limiter drops the runaway label, per-tenant ingest quotas shed lowest-priority series, log lag drains. t6 Recovered: series back to ~12M, lag ~0, query p99 back under 300 ms. Dropped samples during t2-t5 are accepted — timeliness over integrity.

Mitigations. (1) A cardinality limiter at the ingest tier rejects or drops series from labels whose distinct-value count blows past a threshold — the single most important guard, since unbounded cardinality is the number-one way these systems die. (2) Per-tenant / per-priority ingest quotas so a runaway service sheds its own low-value series first, not everyone's. (3) The log buffer turns a hard outage into graceful lag — writes keep being accepted while consumers catch up, and bounded load-shedding drops the least-important samples rather than failing the whole pipeline. Because the workload tolerates loss, deliberately dropping a bounded fraction to protect freshness and availability is the correct behavior, not a bug — exactly the timeliness-vs-integrity choice from L20.

5 · Answer rubric

Good answer (baseline)

  • Identifies the workload as write-heavy, high-cardinality, read-light time-series.
  • Partitions by series id (L11) and uses columnar storage (L04) for compression.
  • Separates recent hot data from old cold data, and downsamples old data to keep it cheap.
  • Gives at least one real sizing number (series count, points/sec, or storage/day).

Better answer (senior signal)

  • Leads with cardinality as the first clarifying question and the driver of the whole design.
  • Puts a partitioned log (L19) in front of storage to absorb the fan-in and enable replay.
  • Stores mergeable sketches (t-digest / histogram / HLL) so p99 and distinct-counts work over millions of series within budget.
  • Explicitly names the timeliness-vs-integrity flip (L20) versus a system of record, and justifies dropping/approximating.
  • Shows the rollup tier doing double duty: cheaper storage and a smaller read scan that meets the query p99 budget.

Red flags

  • Range-partitioning by timestamp — sends every current write to one hot partition (the L11 hot end).
  • Storing raw samples forever in a relational table with a row per point; no compression, no rollup.
  • Claiming exact p99 by averaging pre-aggregated means — mathematically impossible; reveals no grasp of percentiles.
  • Treating it as a system of record (per-sample transactions, quorum writes, linearizable inserts) — massive over-engineering for telemetry.
  • Ignoring cardinality; no plan for a label that explodes the series count.

Likely follow-ups

  • "A single metric becomes a hot series — what happens and how do you fix it?" (L11 hot key: cache or split.)
  • "How do you compute p99 across 50,000 series in 200 ms?" (Mergeable sketches over the rollup tier, not raw scans.)
  • "What stops one team's bad deploy from taking down ingest for everyone?" (Cardinality limits + per-tenant quotas + load shedding.)
  • "What if a storage node crashes mid-flush?" (Replay from the log offset; WAL recovers the head block.)
  • "How would the design change if this fed billing?" (Becomes a system of record — integrity wins; no dropping/approximation.)

Checkpoint exercise

Try it
You are handed a metrics backend with 5,000,000 active series scraped every 10 s, retained raw for 3 days. (a) Compute the sustained ingest rate in points/sec and the compressed raw storage for 3 days at 1.5 bytes/point with replication factor 3. (b) A dashboard asks for "p99 request latency by service, last 12 hours" and there are 8,000 matching series — show why scanning the raw tier blows a 250 ms budget, then describe which rollup tier and which sketch you would query instead and roughly how many points that touches. (c) An engineer adds a user_id label and active series jumps to 40,000,000 — walk the failure timeline and name the two ingest-side guards that contain it. (d) State, in one sentence each, why partitioning by series id (not by timestamp) and storing histograms (not raw means) are both forced by the requirements.

Where this points next

This case closes the applied tour: across lessons 29-34 the same handful of mechanisms — the durable log, partitioning, replication, columnar storage, materialized aggregates, and the timeliness-vs-integrity dial — re-combined to build six very different systems, the metrics store sitting at the lossy-and-approximate extreme. The track's arc, drawn from the first edition of Designing Data-Intensive Applications, still holds entirely. But the field has moved since 2017. The final lesson steps back to ask what the 2nd edition adds — cloud/serverless storage-compute separation, event sourcing, vector search, durable execution, local-first sync, formal methods, and a dedicated ethics chapter — and where each extends the foundations you just used.

Where is truth?
System of record: there isn't a strict one — this is telemetry, so the closest thing to truth is the raw samples in the hot tier (and the partitioned ingest log that briefly holds them), and even those are allowed to drop under load. The emitting services are the real origin; the store is a best-effort observation of them. Copies / derived views: the 1-min and 1-hour rollup tiers and their mergeable sketches (t-digest, histograms, HLL), plus cold object-storage blocks — all derived by downsampling the raw tier. Freshness budget: timeliness is the headline invariant — dashboards stay within a few-hundred-ms query p99 and ingest lag near zero in steady state; under spikes, bounded lag and bounded sample loss are accepted rather than blocking. Owner: the observability-platform team owns the store; each tenant/service owns its own series and label hygiene. Deletion path: data expires by retention (raw days, 1-min weeks, 1-hour a year) and runaway series are dropped by the cardinality limiter — deletion is a TTL/eviction policy, not a transactional erase. Reconciliation/repair: rollups are recomputable by re-downsampling the raw tier while it still exists; a crashed storage node replays from its log offset and recovers the head block from the WAL; there is no cross-tier consistency to reconcile because coarser tiers are pure functions of finer ones. Evidence of correctness: approximate by design — sketches carry bounded relative error, so "correct" means within the stated error band; ingest lag, drop counters, and cardinality metrics are themselves monitored to prove the store is keeping up rather than silently losing more than the budget allows.
Takeaway
A metrics / observability backend is the write-heavy, high-cardinality mirror image of a system of record. Sizing comes first and is driven by cardinality (millions of distinct series); a partitioned durable log (L19) absorbs the ~700k-points/sec fan-in and decouples ingest from storage; data is partitioned by series id (L11, never by timestamp, which would create a hot end) and written columnar and time-partitioned (L04) so delta-of-delta + XOR encoding compress ~33×; a continuous rollup / downsampling pipeline materializes coarser tiers (L04 data cubes) carrying mergeable sketches (t-digest, histograms, HLL) so p99 and distinct-counts answer over millions of series inside a tight query-p99 budget; and recent hot data ages to cheap cold object storage. The unifying decision is the timeliness-vs-integrity flip (L20): unlike the model registry, this system deliberately tolerates dropped samples and approximate answers to keep dashboards fresh and available — and saying that out loud is the senior signal.

Interview prompts