Design Metrics Monitoring

Collect metrics from 100,000 servers, store them compactly, serve dashboards and fire alerts that still work when the monitored systems are failing.

Case study34 minLesson 3 of 7
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

Every server in a company reports numbers every few seconds: CPU, memory, requests served, errors, latency. Engineers look at them on dashboards, and rules watch them around the clock and page someone when error rates climb. When production is on fire, this is the system everyone opens at once, so it must be at its best exactly when everything else is at its worst.

Think of an in-house version of a hosted product such as Datadog. Interviewers ask it because it looks like "store numbers, draw graphs" and hides a write-heavy store that must compress well, a cardinality problem that can take it down, and a design rule most candidates miss: the monitor must not fail along with what it monitors. The observability lesson covers what to measure and how to alert well; this one builds the machine. The steps follow A Framework for Any Design.

Clarify the problem

The first five minutes
Interviewer

Design a metrics monitoring system.

You

Metrics only, meaning numeric time series, or logs and traces too?

Interviewer

Metrics only.

You

How many servers, how many series per server, and how often do they report?

Interviewer

100,000 servers, about 500 series each, every 10 seconds.

You

How long do we keep data, and at what detail?

Interviewer

Full detail for a week, coarser data for two years.

You

Then writes dominate and are steady, and reads spike during incidents. My priorities: alerts must fire even when parts of production are down, a dashboard over the last hour loads in about a second, and losing a few seconds of samples is acceptable. Losing alerting is not.

  • Functional: collect metrics with labels (service, host, status) from every server; query them for dashboards by time range and labels, with sums, rates and percentiles; evaluate alert rules and notify on-call.
  • Non-functional: ingest 5 million samples a second; a sample is queryable within 30 seconds; a 1-hour dashboard panel loads in about 1 second; alerts fire within a minute once a rule's condition has held for its set duration, and keep working during partial outages; raw data for 7 days, rollups for 2 years.
  • Out of scope: logs, traces, anomaly detection, the agent's install and upgrade tooling.

Estimate the scale

  • Series: 100,000 × 500 = 50 million active time series.
  • Samples: 50 million ÷ 10 s = 5 million samples a second, nearly constant all day. At about 20 bytes per sample on the wire, that is 100 MB/s into the system.
  • Raw storage: a sample is a timestamp and a value, 16 bytes uncompressed. 5 × 106 × 86,400 = 432 billion samples a day, 6.9 TB a day uncompressed. Time-series compression (next section) typically gets a sample down to one or two bytes; assume 2: 864 GB a day.
  • Queries: in an incident, say 2,000 dashboards open with 20 panels refreshing every 30 seconds: 2,000 × 20 ÷ 30 ≈ 1,300 queries a second. 100,000 alert rules every 30 seconds add a steady 3,300 a second.
Estimate: Storage with retention tiers

How much storage do raw data for 7 days, 1-minute rollups for 30 days and 1-hour rollups for 2 years take together?

You know
  • 50 million series; 864 GB a day of raw samples after compression
  • A rollup point stores 4 values (min, max, sum, count) at about 2 bytes each: 8 bytes
  • 2 years ≈ 730 days

Show the working
  1. Raw: 864 GB × 7 ≈ 6 TB
  2. 1-minute: 50 × 106 × 1,440 × 8 B = 576 GB a day; × 30 ≈ 17 TB
  3. 1-hour: 50 × 106 × 24 × 8 B = 9.6 GB a day; × 730 ≈ 7 TB
  4. Total ≈ 6 + 17 + 7 = 30 TB

Estimate 30 TB (anything from 10 TB to 100 TB is a good answer)

Keeping raw samples for 2 years would be 864 GB × 730 ≈ 630 TB, about 20 times more.

Estimate: Memory for active series

Each active series costs about 1 KB of memory in the ingesters (its labels, index entries and the open compressed chunk). How much memory do active series need?

You know
  • 50 million active series
  • About 1 KB of memory per active series

Show the working
  1. 50 × 106 × 1 KB = 50 GB, before replication

Estimate 50 GB (anything from 15 GB to 150 GB is a good answer)

Memory grows with the number of series, not samples. That is why one bad label can take the system down.

Your turn: Define the API

Write the write endpoint the agents call, the query endpoint dashboards call, and how an alert rule is defined.

Compare with a model answer

Agents send compressed batches of samples, many series per request. Dashboards send a query expression with a time range and a step. Rules are a query, a threshold and a duration the condition must hold, plus who to notify. The step parameter is the one to argue about: it sets how many points come back.

Define the API

Write, query and rules
POST /v1/write                     from agents, every 10 s, compressed
  body: [ { "labels": {"__name__": "http_requests_total", "service": "checkout",
                       "host": "web-17", "status": "500"},
            "samples": [[1763114700, 48213]] }, ... ]
  204 No Content
  429 Too Many Requests   tenant over its sample-rate limit (agent backs off)
 
GET /v1/query_range?q=sum by (status) (rate(http_requests_total{service="checkout"}[1m]))
                   &start=...&end=...&step=60s
  200 { "series": [ { "labels": {"status": "500"}, "points": [[t, v], ...] } ] }
  422 Unprocessable   query would touch more than 100,000 series
 
Alert rule
  expr:  error ratio for checkout > 5%
  for:   5m                 the condition must hold this long before firing
  notify: team-checkout

The decision worth arguing is step. A panel 1,000 pixels wide cannot show more than about 1,000 points, so the query service picks the coarsest data that still fills it. Asking for a week at a 10-second step returns 60,480 points per series that the browser throws away. Making step part of the API lets the server pick a rollup tier and refuse queries that would touch too many series.

Your turn: Model the data

What identifies a series, how are samples stored, and how does a query find the series matching service="checkout"?

Compare with a model answer

A series is its metric name plus its full label set, hashed to an id. Samples are stored per series in compressed chunks covering a time range. An inverted index maps each label pair to the list of series ids that have it, and a query intersects those lists.

Model the data

Series, chunks and the index
series      id = hash(metric name + sorted labels)
            labels  {__name__="http_requests_total", service="checkout", host="web-17", status="500"}
 
chunk       series_id, start_time, end_time, compressed bytes   (120 samples, 20 minutes)
            only the open chunk stays in memory; full ones go to local disk
 
index       "service=checkout" -> [series ids, sorted]
            "status=500"       -> [series ids, sorted]
            query {service="checkout", status="500"}  =  intersect the two lists
 
blocks      every 2 hours: chunks + index for that window, written as immutable
            files to object storage; compaction merges blocks and builds rollups

The access pattern is unusual. Writes are append-only, always at "now", and arrive for every series at once. Reads ask for a range of time for a set of series, and data is almost never updated. A general database would spend its effort on things this workload does not need. A time-series database (such as Prometheus, M3 or VictoriaMetrics) keeps each series' open chunk in memory and the rest of the last two hours on local disk, then writes immutable, compressed blocks that are cheap to keep in object storage.

Your turn: High-level design

Sketch the boxes from a server's counters to an engineer's dashboard and to a page on someone's phone. Which parts must keep working during an outage?

Compare with a model answer

An agent per host sends batches to a stateless ingest tier, which writes to a log; ingesters build series in memory and flush blocks. A query service reads them. An alert evaluator runs rules through the query service and hands firing alerts to a notifier, which pages through an outside provider. The alerting path must keep working, so it runs apart from what it watches.

High-level design

Writes flow left to right into the time-series store. Dashboards and alert rules both read through the query service; pages leave through an outside provider.Host agents to Ingest service (batch / 10 s); Ingest service to Sample log; Sample log to Time-series store; Engineer to Query service (dashboard); Query service to Time-series store (read series); Alert evaluator to Query service (every 30 s); Alert evaluator to Notifier; Notifier to Paging provider (page).Host agentsIngest serviceSample logTime-seriesstoreEngineerQuery serviceAlert evaluatorNotifierPaging providerbatch / 10 sdashboardread seriesevery 30 spage
Writes flow left to right into the time-series store. Dashboards and alert rules both read through the query service; pages leave through an outside provider.
A sample's life, an alert, a bad label, and the monitor going downUse the arrows or ← → keys
  1. A sample's life, an alert, a bad label, and the monitor going down, step 1Host agents to Ingest service; Ingest service to Sample log; Sample log to Time-series store; Engineer to Query service; Query service to Time-series store; Alert evaluator to Query service; Alert evaluator to Notifier; Notifier to Paging provider.Host agentsIngest serviceSample logTime-seriesstoreEngineerQuery serviceAlert evaluatorNotifierPaging provider
    series in batch
    500

    1The agent on web-17 reads the counters of the processes on its own host, 500 series, and sends one compressed batch to the ingest service. Collecting locally means a dead process shows up as a failed read, not as silence.

  2. A sample's life, an alert, a bad label, and the monitor going down, step 2Host agents to Ingest service; Ingest service to Sample log; Sample log to Time-series store; Engineer to Query service; Query service to Time-series store; Alert evaluator to Query service; Alert evaluator to Notifier; Notifier to Paging provider.Host agentsIngest servicerate okSample logTime-seriesstoreEngineerQuery serviceAlert evaluatorNotifierPaging provider
    series in batch
    500

    2Ingest checks the tenant's sample rate, then appends the batch to the log, partitioned by series id. The log is the durable copy until the store flushes a block.

  3. A sample's life, an alert, a bad label, and the monitor going down, step 3Host agents to Ingest service; Ingest service to Sample log; Sample log to Time-series store; Engineer to Query service; Query service to Time-series store; Alert evaluator to Query service; Alert evaluator to Notifier; Notifier to Paging provider.Host agentsIngest serviceSample logTime-seriesstore2 B per sampleEngineerQuery serviceAlert evaluatorNotifierPaging provider
    lag
    about 5 s

    3The store's ingester for that partition appends each sample to the series' open chunk in memory, compressed as it goes. The sample is queryable now, a few seconds after it was measured.

  4. A sample's life, an alert, a bad label, and the monitor going down, step 4Host agents to Ingest service; Ingest service to Sample log; Sample log to Time-series store; Engineer to Query service; Query service to Time-series store; Alert evaluator to Query service; Alert evaluator to Notifier; Notifier to Paging provider.Host agentsIngest serviceSample logTime-seriesstoreEngineerQuery serviceAlert evaluatorpendingNotifierPaging provider
    error ratio
    7%
    held for
    0 min

    4Checkout's error ratio climbs past 5%. The alert evaluator runs the rule every 30 seconds through the query service; the first time it is true, the alert becomes pending.

  5. A sample's life, an alert, a bad label, and the monitor going down, step 5Host agents to Ingest service; Ingest service to Sample log; Sample log to Time-series store; Engineer to Query service; Query service to Time-series store; Alert evaluator to Query service; Alert evaluator to Notifier; Notifier to Paging provider.Host agentsIngest serviceSample logTime-seriesstoreEngineerQuery serviceAlert evaluatorfiringNotifierPaging provider
    error ratio
    7%
    held for
    5 min

    5Ten evaluations later the condition has held for 5 minutes, so the alert fires. A short spike would have cleared before this and paged nobody.

  6. A sample's life, an alert, a bad label, and the monitor going down, step 6Host agents to Ingest service; Ingest service to Sample log; Sample log to Time-series store; Engineer to Query service; Query service to Time-series store; Alert evaluator to Query service; Alert evaluator to Notifier; Notifier to Paging provider.Host agentsIngest serviceSample logTime-seriesstoreEngineerQuery serviceAlert evaluatorNotifier2 in, 1 outPaging provider
    pages sent
    1

    6Two evaluator replicas run the same rules, so the notifier receives the alert twice. It deduplicates by rule and labels, groups it with related alerts, and sends one page.

  7. A sample's life, an alert, a bad label, and the monitor going down, step 7Host agents to Ingest service; Ingest service to Sample log; Sample log to Time-series store; Engineer to Query service; Query service to Time-series store; Alert evaluator to Query service; Alert evaluator to Notifier; Notifier to Paging provider.Host agentsIngest serviceSample logTime-seriesstoreEngineerQuery service1-min tierAlert evaluatorNotifierPaging provider
    points per series
    10,080

    7The on-call engineer opens the checkout dashboard for the last 7 days. The query service picks the 1-minute tier: 10,080 points per series instead of 60,480 raw ones.

  8. A sample's life, an alert, a bad label, and the monitor going down, step 8Host agents to Ingest service; Ingest service to Sample log; Sample log to Time-series store; Engineer to Query service; Query service to Time-series store; Alert evaluator to Query service; Alert evaluator to Notifier; Notifier to Paging provider.Host agentsIngest serviceSample logTime-seriesstoreseries limitEngineerQuery serviceAlert evaluatorNotifierPaging provider
    new series refused
    5 million

    8A deploy adds a user_id label to a request counter. Within a minute checkout tries to create 5 million new series. The ingesters, which hold every active series, refuse new ones past checkout's limit and alert the team; existing series keep flowing.

  9. A sample's life, an alert, a bad label, and the monitor going down, step 9Host agents to Ingest service; Ingest service to Sample log; Sample log to Time-series store; Engineer to Query service; Query service to Time-series store; Alert evaluator to Query service; Alert evaluator to Notifier; Notifier to Paging provider.Host agentsIngest serviceSample logTime-seriesstoredownEngineerQuery serviceAlert evaluatorno dataNotifierPaging provider
    rules with data
    0

    9A network failure cuts off the store. Rule queries now fail or come back empty, which the evaluator treats as a problem, not as all clear: it fires a "monitoring has no data" alert.

  10. A sample's life, an alert, a bad label, and the monitor going down, step 10Host agents to Ingest service; Ingest service to Sample log; Sample log to Time-series store; Engineer to Query service; Query service to Time-series store; Alert evaluator to Query service; Alert evaluator to Notifier; Notifier to Paging provider.Host agentsIngest serviceSample logTime-seriesstoredownEngineerQuery serviceAlert evaluatorNotifierdownPaging providerno heartbeat
    pages sent
    2

    10Worse, the notifier itself goes down. The notifier normally sends the paging provider a heartbeat every minute; when it stops, the provider pages on its own. Silence is never mistaken for health.

1 / 10

Deep dives

Push or pull

Something has to move numbers from 100,000 hosts to the system. Either the system pulls (scrapes an endpoint on each target) or the targets push.

How metrics get collected
OptionGood atBad atPick it when
Central pull (scrape)The system sets the pace and cannot be flooded; a failed scrape tells you the target is down.Needs service discovery and network reach to every target; short jobs can finish between scrapes.One cluster or data center you control.
App pushes directlyWorks for short jobs, serverless functions and hosts behind NAT.A quiet target and a dead one look the same; a buggy client can flood ingest.Short-lived work with no stable address.
Local agent: pull locally, push batches Usual defaultLocal scraping detects dead processes; batched, compressed pushes cross the network once per host per 10 s; works anywhere.An agent to run on every host; a dead agent must be detected by its own heartbeat.Many hosts across regions and networks. Our choice.

The agent buffers a few minutes of samples on local disk when ingest is slow or returns 429, and backs off with jitter so 100,000 agents do not retry in lockstep. Every agent also sends an up series; an alert on that series going absent is how you tell a dead host from a quiet one.

Storing time series

Compression works because neighbouring samples look alike. Timestamps arrive every 10 seconds: 1000, 1010, 1020, 1031. Store the first, then the difference of differences: 10 − 10 = 0, 11 − 10 = 1. Mostly zeros, which take a single bit. Values change slowly, so store each one XOR-ed with the previous one: an unchanged value becomes a single 0 bit, and a small change leaves only a few meaningful bits. That is how 16 bytes becomes one or two, and why samples are compressed per series in time order.

Downsampling and retention tiers. Old data is looked at in coarse views, so compaction rewrites it: raw for 7 days, 1-minute rollups for 30 days, 1-hour rollups for 2 years. Each rollup point keeps min, max, sum and count, not just an average, so a rollup can still answer "the highest CPU in that hour" and averages over any longer span stay correct (total sum ÷ total count). The tiers cut storage from about 630 TB to 30 TB. Percentiles cannot be rebuilt from those four values, so latency should be recorded as a histogram (counts per bucket), whose buckets roll up by adding.

The cardinality problem. A series is a unique combination of label values, so labels multiply. http_requests_total with 200 services × 50 endpoints × 10 status codes is 100,000 series, fine. Add user_id and every active user becomes new series: 1 million users each hitting 5 endpoint and status combinations is 5 million series, 5 GB of ingester memory at 1 KB each, from one line of code. Memory, index size and query cost all scale with series, so this is the usual way such systems fall over.

  • Limit per tenant: cap active series per team and refuse new ones beyond it, as in the tracer. Existing series keep working.
  • Make it visible: show each team its series count and the labels with the most values.
  • Send unbounded values elsewhere: user ids, request ids and URLs with ids belong in logs and traces, not labels.

The query path

A dashboard query such as "error rate by status for checkout over the last hour" runs in four steps:

  1. Find series: intersect the index's sorted id lists for __name__=http_requests_total and service=checkout. Refuse with 422 if more than 100,000 series match, before reading any data.
  2. Pick a tier: use the coarsest tier that is still finer than the step. A 7-day panel 1,000 points wide has a step of about 10 minutes, so over 1,000 series it reads 10 million 1-minute points instead of 60 million raw ones.
  3. Fan out: series are sharded across ingesters by id, so the query service asks every shard holding matches for recent data, and object storage for older blocks, in parallel.
  4. Aggregate early: each shard computes partial sums by status and returns a few series, not thousands; the query service adds them up.

During an incident hundreds of engineers open the same dashboards. Align each query's start and end to multiples of the step and cache results by (query, step, time range): the older part of the range never changes, so a refresh only computes the newest few points.

Alerts that survive the outage

Evaluate each rule on a fixed schedule through the same query path, and require the condition to hold for a duration before firing, which filters out brief spikes. Run two evaluator replicas so one can die, and deduplicate in the notifier by rule and labels, so redundancy never means double pages. The observability lesson covers which conditions deserve a page.

The harder problem is shared fate. If monitoring runs on the same cluster, network and service discovery as production, the outage that needs an alert can take out the alerting too. Then the system is silent at the worst moment, and silence reads as "all fine".

  • Separate failure domain: run monitoring on its own machines and network path, with no dependency on the services it watches (not their database, not their auth service, not their deploy system).
  • No data is a signal: a rule whose series vanish fires a "no data" alert instead of quietly resolving.
  • Watch the watcher: a small second monitoring stack in another region checks that the main one is scraping, storing and evaluating.
  • Dead man's switch: an alert that is always firing sends a heartbeat to the outside paging provider every minute. If heartbeats stop, the provider pages, as in the last tracer step.

Bottlenecks and failures

At 10x, 50 million samples a second over 500 million series, ingest and the log scale out by adding nodes, and ingesters need 500 GB of memory for active series before replication. The index and query fan-out are next: split tenants onto separate shard groups so one team's huge query cannot slow everyone, and add more rollup tiers. Storage grows to about 300 TB, cheap in object storage.

When this diesWhat happensWhat you do
An ingest nodeAgents' requests to it fail.Agents retry another node; their disk buffer covers the gap.
An ingesterRecent data for its series is briefly unreadable.A replacement replays the log, which keeps a day, from its last flushed block; run 2 replicas per series for reads.
Object storageData older than about 2 hours is unreadable.Recent dashboards and alerts still work from ingesters' memory.
The query serviceDashboards fail; alert evaluation fails.Run it as a stateless fleet; give alerting its own query replicas so dashboard load cannot starve it.
An evaluator replicaNothing visible.The other replica keeps evaluating.
The notifierNo pages go out.Run 2 or 3 that share dedup state. If all die, heartbeats stop and the outside provider pages anyway.

What a strong answer sounds like

LevelWhat the answer does
JuniorServers write samples into a database table; a cron job checks thresholds and sends email.
MidUses a time-series database, picks push or pull with a reason, estimates samples per second and storage, and adds downsampling.
SeniorSizes by series as well as samples, plans for cardinality explosions, explains compression and rollups, designs the query path around step and fan-out, and keeps alerting alive when production and the monitor itself fail.
Who watches the monitorStrong answer
Interviewer

Your monitoring runs in the same data center as production, and the data center loses its network. What happens?

You

As described, nothing pages, which is the worst outcome. So I'd run alerting outside production's failure domain, treat "no data" as an alert, and have the notifier send a heartbeat to an outside paging provider every minute. If the whole monitoring stack goes dark, the heartbeat stops and the provider pages on-call. The cost is a second small stack and an outside dependency.

Interviewer

Why not just run two copies of everything?

You

Two copies in the same failure domain fail together. What matters is that the thing that pages depends on nothing that the outage can take down.

Follow-up questions

How do you compute p99 latency across 1,000 servers?

Not by averaging each server's p99, which is meaningless. Each server records a histogram of request counts per latency bucket; add the buckets across servers, then read the 99th percentile from the summed histogram.

Can you lose samples?

A few seconds' worth, yes, if an agent's disk buffer fills. That is acceptable for metrics; the guarantee to protect is that alerts keep firing.

How do you support many teams fairly?

Per-tenant limits on samples per second, active series and query cost, separate shard groups for the largest tenants, and showing each team its usage.

How would you detect anomalies, not just thresholds?

Compare the current value with the same hour in previous weeks, or with a moving baseline, as an extra rule type. It runs on the same evaluator and query path.

What about very short-lived jobs?

A batch job that runs for 3 seconds can finish between collections. It pushes its final values to the local agent before exiting, and the agent sends them on with its next batch.

Self-review: metrics monitoring0 of 12

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure