system-design

Design a Metrics & Monitoring System (Prometheus / Datadog)

Difficulty: Tier 2 Asked at: Datadog, Amazon, Google, infra-heavy companies Time budget: 45 min

A monitoring system ingests a firehose of numbers — every server’s CPU, every service’s latency, every counter — and lets you query and alert on them. It’s a time-series problem: high-volume append-only writes, aggregation over time windows, and a storage engine tuned for exactly that. The signature ideas are the time-series data model, downsampling/retention, and the write-heavy ingestion pipeline.

Prerequisites: Time-Series Databases, Batch vs Stream, Observability


1. Requirements

Functional:

Non-functional:

Out of scope: log aggregation (separate), distributed tracing (mention the three pillars).


2. Estimation


3. The data model: time series

🚨 A metric is a time series: (metric_name, {tags}) → [(timestamp, value), ...].

cpu_usage{host="web1", dc="karachi"}  → [(t0, 0.42), (t1, 0.45), ...]
http_latency_p99{service="checkout"}  → [(t0, 120), (t1, 135), ...]

4. High-level design

flowchart LR
    Agents[Agents / exporters<br/>on each host] -->|push/pull metrics| Ingest[Ingestion tier]
    Ingest --> TSDB[(Time-Series DB<br/>append-only, compressed)]
    Ingest --> Stream[Stream processing<br/>rollups, alerting]
    TSDB --> Query[Query engine<br/>range + aggregation]
    Query --> Dash[Dashboards]
    Stream --> Alert[Alerting engine]
    TSDB --> Downsample[Downsampling / retention jobs]

Ingest (push from agents, or pull/scrape à la Prometheus) → write to a time-series database optimized for append-heavy writes and range reads → query engine for dashboards → stream processing for real-time rollups and alerting → downsampling/retention to age out old data.


5. Deep dives

5a. Why a time-series DB (not a general one)?

Metrics have a special shape: append-only, timestamp-ordered, queried by time range + aggregation. A purpose-built TSDB exploits this:

5b. Downsampling & retention

You can’t keep 1M points/sec at 10s resolution forever. Downsample: keep raw data for days, then aggregate to 1-min resolution for weeks, 1-hour for months/years. Old data loses resolution but stays queryable and cheap. Retention policies delete or roll up automatically. 🚨 Resolution decreases with age — nobody needs per-second data from a year ago.

5c. Ingestion at scale (write-heavy)

1M writes/sec needs a horizontally-scaled ingestion tier, sharded by series (hash of metric+tags) so each series goes to a consistent shard. Buffer/batch writes; the TSDB’s write path is append-optimized. Handle bursts (an incident spikes metric volume) with buffering. Push vs pull: pull (scrape) gives the system control over rate and knows what’s up; push suits ephemeral/serverless sources.

5d. Querying & aggregation

Queries are range + aggregation: “p99 latency for service=checkout over the last 6h, by 1-min buckets.” The engine reads the relevant series’ time ranges and aggregates. Precompute common rollups (recording rules) so dashboards don’t recompute heavy aggregations every refresh. Cache dashboard queries.

5e. Alerting

Continuously evaluate rules (threshold, rate-of-change, anomaly) against incoming/recent data via stream processing, firing alerts to notification channels. Must be reliable and low-latency — you need alerts during incidents. Deduplicate/group alerts to avoid storms. (Observability)

5f. High cardinality — the classic pitfall

🚨 Each unique combination of metric name + tag values is a separate series. Putting a high-cardinality value (user ID, request ID, full URL) in a tag creates millions of series → memory/index blowup. Guard against it: bound label cardinality, reject/limit runaway series. This is the operational failure mode of metrics systems.


6. Bottlenecks & scaling further

  1. Write volume → TSDB with append/LSM writes, sharded ingestion by series.
  2. Storage cost → time-series compression + downsampling + retention.
  3. Cardinality explosion → bound label cardinality; the top real-world failure.
  4. Query cost → precomputed rollups (recording rules), caching, time-partitioned reads.
  5. Ingestion availability → buffering, redundancy — never lose metrics during incidents.

7. Trade-off summary

Decision Chosen Alternative Why
Storage Purpose-built TSDB General SQL DB Append/compression/range-scan fit metrics
Retention Downsample by age Keep all at full resolution Old data rarely needs high resolution; huge savings
Ingestion Sharded by series Single writer 1M writes/sec needs horizontal scale
Aggregation Precomputed rollups Compute on every query Dashboards recompute constantly
Labels Bounded cardinality Arbitrary tags Unbounded cardinality kills the system

8. Follow-up questions

Why use a specialized time-series database instead of a regular database? Because metrics have a specific shape that a purpose-built store exploits and a general database handles poorly. Metric data is append-only, arrives in timestamp order, and is almost always queried as an aggregation over a time range for a set of series — never random single-row updates. A time-series database is built for exactly this: it stores data ordered by time (often columnar) so range scans are fast, uses LSM-style append-optimized writes to absorb the enormous write volume, and applies time-series-specific compression (delta-of-delta on timestamps, XOR on values) that shrinks the data roughly tenfold because consecutive points are so similar — which is what makes storing a million points a second affordable. A general SQL database, tuned for transactional row updates and arbitrary queries, would struggle with the write throughput, store the data far less efficiently, and lack the built-in downsampling and retention that time-series workloads require. Matching the storage engine to the access pattern is the whole point.
You can't keep a million points a second forever. How do you manage storage over time? Through downsampling and retention policies that trade resolution for age. Recent data is kept at full resolution (say every 10 seconds) because that's what you need when debugging something happening now, but as data ages you aggregate it into coarser buckets — raw for a few days, one-minute averages/percentiles for a few weeks, one-hour rollups for months or years — and eventually delete it entirely. This works because nobody needs per-second granularity from a year ago; for old data you only want trends, which coarse rollups capture at a tiny fraction of the storage. The system runs background jobs that roll up and expire data automatically according to configured retention tiers. Combined with the heavy compression time-series data already enjoys, downsampling keeps total storage bounded and cost-effective while preserving high-resolution recent data and long-term low-resolution history — resolution decreasing with age is the key idea.
What is a cardinality explosion and why is it the classic failure mode? Cardinality is the number of distinct time series, and every unique combination of metric name plus tag/label values is a separate series that the system must index and hold state for. A cardinality explosion happens when a label takes on a huge or unbounded number of values — putting something like user ID, request ID, session ID, or a full URL into a tag — because each distinct value multiplies the series count, quickly producing millions or billions of series. That blows up the in-memory index and per-series overhead, causing memory exhaustion, slow queries, and ingestion failures, often taking the monitoring system down precisely when it's needed. It's the classic failure mode because it's easy to do accidentally (a developer adds a convenient high-cardinality label) and its cost is nonlinear and often invisible until it's severe. The defense is to keep labels low-cardinality and bounded (use tags for things with few stable values like service, host, datacenter — not per-user or per-request identifiers), and to enforce limits that reject or cap runaway series before they overwhelm the system.
How do you keep dashboard and alert queries fast over huge volumes? By precomputing common aggregations and reading only the data a query needs. Dashboards and alerts repeatedly ask for the same heavy aggregations (p99 latency per service per minute, average CPU per datacenter), so instead of recomputing those from raw points on every refresh, you evaluate recording rules that continuously roll raw data into precomputed aggregate series; queries then read the cheap rollup rather than scanning millions of raw points. Time-partitioned, time-ordered storage means a range query touches only the relevant time blocks, not the whole dataset, and reading downsampled tiers for older ranges cuts data volume further. Caching dashboard query results for their refresh interval avoids redundant work, and alerting evaluates rules against the incoming stream and recent data rather than re-querying history. So the combination of precomputed rollups, time-partitioned reads, downsampled tiers, and caching keeps interactive queries fast despite the underlying volume.

9. What junior / mid / senior answers look like


Further reading