SYSTEM DESIGN · DESIGN PROBLEMS · PLATFORM AND ASYNC · MEDIUM · 41 OF 41

Design Monitoring and Alerting: Millions of Time Series, Fast Dashboards and Pages That Matter

How to design a metrics monitoring and alerting system for a hundred thousand machines: collecting samples by pull or push, a buffered write path, a time-series database with compressed chunks and an inverted label index, the cardinality trap, downsampling for long ranges, rule evaluation with pending periods, an alert manager that groups and silences, and how to monitor the monitor.

Reported atMicrosoftPhonePe

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 20

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Listen. We collect numeric metrics, such as request counts, latency and CPU, each tagged with labels like service and host. We keep raw data for fifteen days and downsampled data for a year. Users query and graph rates, sums and percentiles grouped by label. Alert rules fire when an expression stays true long enough, and alerts are routed, grouped and silenced. Logs and traces are out of scope.

The qualities. Ingest is constant and huge, and must never slow down the services it watches. Dashboards should load in about a second for an hour of data. Alerts should fire within a minute of a problem. The monitoring system must be more available than everything it monitors. Losing a few samples is acceptable. Losing a page is not.

Functional
  • collect numeric metrics with
  • labels (service, host, ...)
  • store them: raw for 15 days,
  • downsampled for 1 year
  • query and graph: rates,
  • sums, percentiles by label
  • alert rules: expr + duration
  • route, group and silence
  • alerts to on-call
  • out of scope: logs, traces
Non-functional
  • ingest is constant and huge;
  • never block the services
  • dashboards: ≤ 1 s for 1 hour
  • ≤ 5 s for 30 days
  • alert within ~1 min of issue
  • more available than anything
  • it watches
  • a few lost samples are fine;
  • a lost page is not

Ask scale

Scale. A hundred thousand hosts with a thousand series each is a hundred million active time series. Sampling every fifteen seconds gives almost seven million samples a second. That sounds scary, but good compression stores a sample in about one and a half bytes, so roughly ten megabytes a second, under a terabyte a day. Dashboards add about seven thousand queries a second.

Back of the envelope
100,000 hosts × 1,000 series
  = 100M active series
one sample every 15 s
  → 100M / 15 ≈ 6.7M samples/s

compressed ≈ 1.5 bytes/sample
  6.7M × 1.5 B ≈ 10 MB/s
  ≈ 860 GB/day, 15 days ≈ 13 TB

queries: 10K open dashboards
  × 20 panels / 30 s ≈ 7,000/s

Define API

The API. Writes are batched and compressed, thousands of samples per call. Queries come in two forms: an instant query for one moment, and a range query with a start, an end and a step, which is what a graph needs. Rules have an expression, a for duration, a severity and a link to a runbook. Silences mute matching alerts during planned work.

API
POST /v1/write   (batched,
  snappy-compressed samples)
GET  /v1/query?expr=…&time=…
GET  /v1/query_range?expr=…
     &start=…&end=…&step=60s
POST /v1/rules
  { expr, for: "5m", severity,
    labels, runbook_url }
POST /v1/silences
  { matchers, until, reason }

Data model

Data model. A series is a metric name plus a sorted set of labels, hashed into a series id. Each sample is just a timestamp and a floating point value, so samples are packed into compressed chunks of about two hours per series. An inverted index maps each label value to the list of series that have it, which is how queries find their series.

Data model
series = name + sorted labels
  http_requests_total
  {service="cart", code="500"}
  → series_id = hash(...)

samples: (series_id, ts, value)
  packed in 2-hour chunks

inverted index:
  service="cart" → [ids …]
  code="500"     → [ids …]

High-level design

The write and read paths. Collectors scrape each host every fifteen seconds, or receive pushed samples, and write batches to Kafka. Ingesters consume Kafka and write each series to its shard of the time-series database. Dashboards send queries to a query service that fans out to the shards, merges results and caches them.

Alerting reuses the store. A rule evaluator runs each rule's query every thirty seconds and sends firing alerts to an alert manager, which groups, deduplicates and routes them to the right on-call person. Older data is compacted and copied to object storage, the long-term store, at lower resolution.

Deep dive: collection

Deep dive one: collection. With pull, collectors scrape each target's metrics endpoint, found through service discovery. A failed scrape tells you the target is down, which is useful. With push, agents send samples, which suits short-lived jobs and devices behind firewalls, but silence is ambiguous. Many systems pull by default and offer a push gateway for batch jobs.

Pull vs push
  • pull (scrape /metrics):
  • + a failed scrape = target
  • is down, a signal itself
  • + targets stay simple
  • − needs service discovery
  • push (agent sends):
  • + short-lived jobs, devices
  • behind NAT
  • − silence is ambiguous
  • common: pull, with a push
  • gateway for batch jobs

Deep dive: storage

Deep dive two: storage. Recent samples are appended to an in-memory head block, protected by a write-ahead log. Every two hours the head becomes an immutable block on disk, and compaction merges small blocks into bigger ones. Compression is the trick: timestamps arrive every fifteen seconds, so the delta of the delta is usually zero, about one bit. Values are XORed with the previous one, and most bits cancel.

The cardinality trap. The number of series is the product of label values. Service, status code and path might give ten thousand series. Add a user id label, with a million users, and you have ten billion series, and the index runs out of memory. Enforce series limits per team, reject labels like user id or request id, and put per-user detail in logs or traces instead.

Inside the time-series DB
  • head block in memory
  • + write-ahead log on disk
  • every 2 h → immutable block
  • of compressed chunks
  • compaction merges blocks
  • compression:
  • timestamps: delta-of-delta
  • (15, 15, 15 → 0, 0 ≈ 1 bit)
  • values: XOR with previous
  • → ~1.5 bytes per sample
The cardinality trap
series = product of label values

requests{service, code, path}
  20 × 10 × 50 = 10,000 series

add user_id (1M users):
  → 10 billion series
  → index and memory explode

fix: per-tenant series limits,
  reject high-cardinality labels

Deep dive: queries

Deep dive three: queries. First intersect the posting lists for each label, which narrows a hundred million series to, say, forty. Then read only those series' chunks for the requested range and compute the rate, sum or percentile. For thirty days, read five-minute or hourly rollups instead of raw samples. Cache results keyed by expression, step and an aligned time window.

Downsampling. Keep raw fifteen-second data for fifteen days, five-minute rollups for ninety days, and hourly rollups for a year or more. Each rollup stores min, max, sum and count, so averages stay correct and short spikes survive in the max. One important rule: you cannot average percentiles. Store latency as histograms, which can be added up, and compute p ninety-nine from the merged histogram.

Answering a query fast
  • 1. index: service="cart" ∩
  • code="500" → 40 series
  • 2. read their chunks for
  • the time range only
  • 3. compute rate, sum, p99
  • long ranges: use 5-minute
  • and 1-hour rollups
  • cache results by (expr, step,
  • aligned time window)
Downsampling
raw 15 s     → keep 15 days
5-minute     → keep 90 days
1-hour       → keep 1 year+

each rollup stores
  min, max, sum, count
  → avg = sum / count
  → spikes survive (max)

percentiles: store histograms,
  never average p99s

Deep dive: alerting

Deep dive four: alerting. A good rule uses a rate over a window, like errors divided by requests over five minutes above two percent. The for clause makes it pending first and fire only after five minutes of being true, which stops flapping. Page on symptoms users feel, such as errors, latency or a fast error budget burn. Causes like disk at eighty percent should open a ticket, not wake someone.

The alert manager turns noise into one message. It deduplicates the same alert from two redundant evaluators. It groups by cluster and alert name, so a hundred and twenty hosts going down becomes one page. Inhibition mutes everything inside a zone that is already known to be down. Silences cover planned maintenance, and routing sends each alert to the owning team's rota.

Good alert rules
alert: CartErrorsHigh
expr:  rate(errors[5m])
     / rate(requests[5m]) > 0.02
for:   5m   (pending → firing)
labels: severity=page, team=cart
runbook: wiki/cart-errors

page on symptoms users feel
  (errors, latency, SLO burn)
ticket on causes (disk 80%)
Alert manager
  • dedupe: same alert from 2
  • evaluators → one
  • group: by cluster + alert
  • → 120 hosts = 1 page
  • inhibit: zone down mutes
  • every alert inside it
  • silence: planned maintenance
  • route: team label → rota
  • repeat every 4 h if unresolved

Deep dive: meta

Last hard part: who monitors the monitor? Run it in a separate failure domain, so the outage it should report cannot also take it down. Run two rule evaluators and a small cluster of alert managers that deduplicate between them. Add a dead man's switch: an alert that always fires, sent to an outside service that pages you if it ever stops arriving.

Monitor the monitor
  • run in a separate failure
  • domain from production
  • two evaluators + 3 alert
  • managers (gossip, dedupe)
  • dead man's switch: an alert
  • that always fires; an
  • outside service pages if
  • it stops arriving
  • test pages weekly

Trade-offs

Failure modes. If ingesters fall behind, Kafka buffers and dashboards lag, but nothing is lost. A dead database shard is covered by a replica. A single enormous query can hurt everyone, so limit series and samples per query and time out. Under overload, drop low-priority metrics first and protect the series that alert rules depend on.

Monitor ingest lag and dropped samples, active series per team, query p ninety-nine, rule evaluation delay, and the human metric: pages per week and how many were actionable. At ten times the scale, add shards by series hash, give each team its own tenant with quotas, and compute rollups in the stream instead of later.

Failure modes
  • ingesters slow → Kafka
  • buffers; dashboards lag
  • TSDB shard down → replica
  • (replication factor 2–3)
  • query of death (huge range) →
  • limits on series and samples
  • per query, timeouts
  • overload → drop low-priority
  • metrics, keep alert inputs
Monitor and grow
  • monitor:
  • ingest lag, dropped samples
  • active series per team
  • query p99, rule eval delay
  • pages per week, % actionable
  • at 10x:
  • more shards by series hash
  • per-team tenants and quotas
  • rollups computed in stream

Say it like this

In the interview: pull metrics through service discovery, buffer in Kafka, and store series in a time-series database with an in-memory head, a write-ahead log and compressed two-hour blocks. Find series through an inverted label index, and cap cardinality. Downsample with min, max, sum and count, and use histograms for percentiles. Alert on symptoms with a for duration, and let an alert manager group and route.

Interview summary
  • pull + service discovery
  • collectors → Kafka → ingesters
  • TSDB: head + WAL, 2 h blocks
  • delta-of-delta, XOR ≈ 1.5 B
  • inverted label index
  • cap cardinality per team
  • rollups: min/max/sum/count;
  • histograms for percentiles
  • rules with for:, symptoms
  • alert manager: group, inhibit

Interview questions

Should metrics be pulled or pushed?

Pull with service discovery is the common default because a failed scrape signals that a target is down and targets stay simple, while push suits short-lived batch jobs and devices that cannot be reached directly.

How does a time-series database compress samples so well?

Timestamps arrive at regular intervals, so storing the delta of deltas usually needs about one bit, and each value is XORed with the previous one so unchanged leading and trailing bits are skipped, giving around 1.5 bytes per sample.

What is high cardinality and why is it dangerous?

Cardinality is the number of distinct label combinations; adding a label such as user ID multiplies the series count by millions, which exhausts index memory and slows every query, so systems enforce per-tenant series limits.

How does a query find the right series among millions?

An inverted index maps each label value to a posting list of series IDs; the query intersects those lists to get the few matching series and reads only their chunks for the requested time range.

How do you keep queries over 30 days fast?

Downsample older data into five-minute and hourly rollups that store min, max, sum and count, and have the query service read the coarsest rollup that fits the requested step, with result caching.

Why can't you average p99 latencies across servers?

Percentiles are not additive, so the average of per-server p99s is not the overall p99; record latency as histograms, which can be summed across servers and over time, and compute the percentile from the merged histogram.

How do you reduce alert fatigue?

Alert on user-facing symptoms with a for duration to avoid flapping, send causes to tickets instead of pages, and use an alert manager to deduplicate, group related alerts, inhibit alerts under a known outage and honour silences.

How do you know the monitoring system itself is working?

Run it in a separate failure domain with redundant evaluators and alert managers, and use a dead man's switch: an always-firing alert sent to an external service that pages if it stops arriving.