Blueprint
Infrastructure intermediate

Design a metrics + monitoring system

Time-series data at hundreds of millions of points a second, queried by dashboards under stress, retained for months — a storage engine problem masquerading as an ingestion one.

asked at Datadog · Grafana · Cloudflare Rarely asked ~35 min walkthrough
01

Scope the requirements

This is a storage system dressed up as a monitoring product. The interview is about the write path (millions of points/s), the read path (dashboards over hours-to-months), and the killer axis: cardinality — how many distinct time series exist.

Functional — what it must do

  • Agents on every host push metrics (counters, gauges, histograms) every 10-60 s.
  • PromQL-like queries over time ranges: rate, aggregate by label, over hosts/services.
  • Alerts: run a query at intervals, page on threshold breach.
  • Retention tiers: raw for 24h, 1-min for a month, 1-hour for a year.
  • Explicitly cut: distributed tracing, logs — different products, different systems.

Non-functional — the numbers that shape the design

  • Ingest: 100M data points/s across the fleet.
  • Ingest lag under 10 s from write to queryable.
  • Query p95 under 1 s for dashboards over the last 24 hours.
  • Cardinality: support 100M distinct time series without collapsing.
  • 99.9% availability on ingest; queries can degrade to older data during incidents.
Say this out loud

The number that decides the whole design is cardinality, not points-per-second. A stupid label choice ("user_id as a label") explodes the series count into the billions and kills the storage engine — call that out early.

02

Back-of-envelope estimates

Compression is the reason this works at all — raw storage would be untenable. Gorilla compresses to ~1.5 bytes per point in practice.

QuantityEstimateHow you got there
Points/s~100M1M hosts × 100 series/host × 1 point / 10 s = 10M/s per fleet, ×10 for a large product.
Distinct series~100Mhost × metric × label combinations. The knob that most needs guardrails.
Raw storage/day~13 TB100M pts/s × 86400 s × ~1.5 B (compressed). Uncompressed would be ~70 TB.
Monthly rollup storage~200 GB1-minute buckets × 100M series × 30 days × 8 B.
Query fanoutup to ~1M seriesa broad dashboard aggregation ('rate(http_requests) by service') can span this many series.
Alerts~10k rules × 1 mineach rule = one query per minute; ~170 QPS steady-state, spiky during incidents.
Say this out loud

1.5 bytes per point is not a typo — Gorilla and its descendants make time-series data 10× smaller than a naive representation. Without that, we don't fit.

03

Sketch the API

Push and query. Push is a firehose; query is the interactive path that must stay fast even at the tail.

POST/api/v1/ingestbody: batch of { metric, labels, value, ts }. Agent-side batched to ~5s worth.
POST/api/v1/querybody: { promql, start, end, step }. Returns time-series matrix.
POST/api/v1/query_rangeinstant version for alert evaluation; runs at 1 evaluation.
POST/api/v1/alerts/rulesCRUD for alert rules; evaluated by a separate scheduler.
  • Ingest is agent-push, not central pull at this scale — a scraper trying to poll 1M hosts is a nightmare and scales worse. Prometheus-style pull is fine at small scale; at 100M points/s the push model wins.
  • Queries never scan raw storage for windows longer than a day — the query planner picks the coarsest rollup that satisfies the requested step.
04

Data model

Two data structures do all the heavy lifting: an inverted index of labels → series-ids, and a per-series compressed block of (timestamp, value) points.

series_index
(metric, labelset) → series_id · inverted-posting-lists per label
the query router: label filters resolve to a set of series_ids.
blocks
series_id → chunks of (ts_delta, value_xor)
Gorilla or Prom-TSDB blocks; ~2h per block, immutable, mmap'd. Sorted for fast range scan.
rollups
(series_id, minute|hour) → agg (min, max, sum, count)
precomputed after each block finalises; queries over long ranges hit these.
meta
metric names, label cardinality counters, retention policies
small; SQLite/Postgres. Used by the query planner.
The database call

This is a purpose-built time-series database — Prometheus TSDB, InfluxDB, VictoriaMetrics — not a general-purpose store. The reason: general databases don't support Gorilla-style compression, don't have label-inverted indexes tuned for time-range scans, and their write-amplification kills the ingest rate. Say "I would use or build a time-series database" — the interviewer wants you to know this is a distinct category.

05

High-level design

Ingest tier fans out across shards keyed on series_id. Each shard owns some subset of the total series. Query is scatter-gather across shards, with a query planner that picks the right rollup tier.

sources edge service storage async Host agent App SDK Ingest LB Query LB Ingester in-memory WAL Query svc planner Alerts TSDB shard A TSDB shard B TSDB shard C S3 rollups Pager push hash roll scatter
shards partition the series-id space. queries scatter, gather, aggregate. rollups flow out to object storage as blocks close.
  1. Agent batches ~5 s worth of points and POSTs to ingest LB. Ingester hashes each series to a shard (consistent hashing on series_id).
  2. Ingester appends to a shard's in-memory buffer + WAL. Every ~2h, the buffer is compacted into an immutable block: sorted, Gorilla-compressed, indexed by labels.
  3. Blocks older than 24h are uploaded to S3 as rollup Parquet; local disk keeps only recent hot data. Query planner knows which tier holds which range.
  4. Query arrives → planner resolves label filter to a series_id set → determines the coarsest rollup satisfying step → scatters to shards holding those series → shards return partial results → planner merges + applies aggregators.
  5. Alerts service polls the query API on each rule's schedule; a positive result generates a notification event → pager routes to the right on-call.
06

Deep dives — where the interview is won

Phase 01

Gorilla compression: 16 bytes per point becomes 1.5

Naive storage per point is 8 bytes timestamp + 8 bytes value = 16 B. Gorilla's insight: consecutive points are highly correlated. Timestamp deltas between samples are almost always constant, so encode delta-of-delta — a stream of small integers, often zeros, that Elias-Gamma or similar bit-packs to ~1-2 bits each. Values are floats that change slowly; XOR consecutive values and the result has long runs of zero leading bits — encode the meaningful nibble and its position, and you get ~10 bits per value on typical monitoring data.

The whole scheme is designed for the read pattern: sequential scan of a time-series segment. Random access to point N inside a block requires decoding from the block's start, but that's ~10 µs for a 2h block, and dashboards never ask for a single point. Compression ratios are ~10-20× versus naive, and the CPU cost of decode is minimal compared to disk or network.

raw encoded ts stream 8B each value stream 8B each Δ-of-Δ ~1-2 bits XOR nibble ~10 bits encode
two independent encoders for the two streams — both exploit the fact that monitoring data barely moves point-to-point.
Trade-offBlocks are append-only and immutable — you can't update a point after the block is closed. If you need to correct history (rare in monitoring), a shadow overlay column handles it.
Phase 02

The cardinality bomb and how to defuse it

The single biggest failure mode: someone puts a high-cardinality field into labels. user_id, request_id, trace_id, email — any of these turns one metric into millions of distinct series, each with its own compressed block, its own index entry, its own memory. The system doesn't crash gracefully — it slowly gets slower until queries time out and the on-call is paged.

Defences run at three layers. Agent-side: SDKs cap the number of distinct label values a client can emit (http_requests with endpoint allowed, endpoint=/user/123 normalised to /user/:id). Ingest-side: reject series that would push a metric past a cardinality budget, with alerts to the team owning that metric. Query-side: enforce a hard limit on series count per query so a runaway PromQL doesn't take down the shard fleet. All three are necessary — one alone leaks eventually.

agent ingester query SDK normaliser /user/:id Cardinality budget Query series cap series
three defence layers. no single layer is enough; leaks compound.
Trade-offStrict enforcement means legitimate use cases get rejected sometimes. The alternative — no limits — is the system falling over silently, which is worse. Make the rejection visible and actionable.
Phase 03

Query planner: pick the tier before you scan

A dashboard asks for the last 30 days of rate(http_requests[5m]) by service. If you scanned raw 10-second data, that's a billion points read and reduced. The right move: precompute rollups (1-minute and 1-hour) at write time. The planner reads the query step and range and picks the coarsest tier that satisfies. For a 30-day range at 1-hour resolution, the 1-hour rollup answers in milliseconds.

The planner also decides scatter-gather scope. If the query is by service and there are 100 services, it hits every shard that holds any matching series. If the query names specific labels (service=api), the series index resolves to ~1 shard's worth and only that shard is queried. The interview answer that lands: 'the planner is the whole query-latency story; without it, every query is a scan.'

query planner tier Dashboard Planner range + step Raw 10s 24h 1-min rollup 30d 1-hr rollup 1y promql ≤30d <24h >30d
three tiers. the planner reads range + step and picks. no query touches raw for a month-long dashboard.
Trade-offRollups store fewer aggregate functions — min/max/sum/count. Percentile queries (p99 latency) need pre-aggregated histograms because you can't compute a percentile from a mean. Choose your aggregators at write time or accept a subset of queryable functions on long ranges.
07

Follow-ups you should expect

Prometheus pull vs push?
At small scale, pull is beautiful: the monitoring system knows what to scrape, and dead targets are visible. At 1M hosts, pull creates a service-discovery problem (the scraper has to know all endpoints), amplifies during rescheduling, and puts the read direction on the wrong side of firewalls. Push scales further; the trade-off is that lost pushes are lost data, so the ingest tier needs high availability.
How do you avoid overwriting history when clocks drift?
Ingesters reject points more than N minutes off the current time (both future and past). Small drift is accepted; large drift is a signal of a broken clock and dropping the sample is safer than corrupting the block. Alerts fire on rejection rates so ops sees drifting hosts.
How do you handle alert storms?
Two techniques: alert grouping (aggregate related alerts into one incident before paging) and inhibition (a broader alert silences narrower ones — 'datacenter down' silences all 'host down' alerts inside it). Both live in the alert router, not the storage engine.
What breaks when a shard dies?
The series it owned become unqueryable and, unless you replicate, unwriteable. Two answers: replicate each shard 2-way for durability, and accept that during a shard failover, queries return partial results (a subset of series missing) with an error flag. Prometheus HA is two independent scrapers; a purpose-built system replicates the WAL.
Downsampling on read vs write?
Always write-time in production. Read-time downsampling makes every query re-scan raw data, which doesn't scale. Compute the ladder as blocks close (2h at raw, roll into 1-min the next day, into 1-hr the next month), retire the finer tier when its retention expires.

Where candidates lose the room

  • Storing metrics in Postgres or Elasticsearch — they cost 10-50× the disk and can't sustain the write rate.
  • Treating cardinality as "just a number" rather than the number that decides survival — leaks turn a fast system into a slow one over weeks.
  • Not building rollups — a month-range dashboard on raw data is a full block scan of 10 TB of compressed points.
  • Pull-based scrapes at fleet scale — the moment a datacenter reschedules 10k containers, the scrapers can't keep up.
  • Alerting from the raw ingest tier instead of the same query planner — alerts silently drift out of sync with dashboards showing the same query.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.