Design a Metrics and Monitoring System
A million samples a second from 100K hosts: batch and queue the ingest, compress into a time-series store, roll up old data, and alert without noise.
- 1M samples/sfrom 10M series every 10 seconds
- ~118 GB/daycompressed, against 1.38 TB raw
- ~10.6 TBfor 15 days raw plus two years of rollups
Show what to say
“100K hosts times 100 metrics is 10M series, so 1M samples a second. Agents batch and push through Kafka, which absorbs bursts, into a time-series database with an in-memory head and a write-ahead log, flushed to compressed blocks: about 1.4 bytes a sample against 16. Rollups keep min, max, sum and count for long retention, and alerts are state machines with a for-duration.”
Trade-offs
- Push vs pull: push suits short-lived jobs and NAT, but the backend must protect itself; pull gives control but needs service discovery.
- Rollups vs raw: years of history for a fraction of the space, but raw detail is lost and percentiles need histograms.
- Long vs short for-duration: fewer false pages, but slower detection of real problems.
Bring it up before they ask
Label cardinality: a label like user_id multiplies series by millions, so enforce per-team series quotas and reject unbounded label values.
Agents push batches through into a that compresses a sample to about 1.4 bytes. Keep recent data raw and old data in , and evaluate alert rules as small state machines with a for-duration. The two risks are too many distinct series and noisy pages.
Functional
- Collect metrics (cpu, memory, requests, latency) from every host and service
- Store them with labels such as host and service
- Query ranges and aggregate for dashboards
- Define alert rules and notify on-call
- Keep long-term history at lower resolution
Non-functional
- 1M samples a second from 100K hosts
- Dashboards load in under a second
- Alerts fire within about a minute of a real problem
- Monitoring must stay up when the system it watches is down
- Cost: keep a year of history without a petabyte
Out of scope
- Logs and traces (separate pipelines)
- Anomaly detection with machine learning
- Per-request tracing
Scope, scale and API
Ask three things: push or pull (agents push here), resolution and retention (10 seconds for how long, then what?)
Read more · 206 words
and cardinality (distinct label combinations set the cost).
| Quantity | Calculation | Result |
|---|---|---|
| Series | 100K hosts × 100 metrics | 10M |
| Samples | 10M ÷ 10 s | 1M/s |
| Raw size | 1M × 16 B × 86,400 s | 1.38 TB/day |
| Compressed | 1M × 1.37 B × 86,400 s | ≈ 118 GB/day |
| Raw, 15 days | 118 GB × 15 | 1.8 TB |
| 1-minute rollups, 90 days | 10M × 1,440 × 6 B × 90 | 7.8 TB |
| 1-hour rollups, 2 years | 10M × 24 × 6 B × 730 | 1.05 TB |
| Dashboard queries | 10K open × 10 panels ÷ 30 s | ≈ 3,300/s |
| Alert queries | 10K rules ÷ 30 s | ≈ 333/s |
About 10.6 TB holds two years of history. The 1.37 bytes a sample is the average reported in Facebook's Gorilla paper; your data will differ. Writes dominate: queries are few and hit small, recent or pre-aggregated data.
The API:
| Call | Notes |
|---|---|
POST /v1/write | Batch of { labels, samples: [{ t, v }] }, compressed |
GET /v1/query_range | query, start, end, step → series of points |
PUT /v1/rules | Alert rule: query, threshold, for, labels, route |
GET /v1/alerts | Pending and firing alerts |
A series is one metric name plus one set of labels, such as cpu{host="web-17", dc="eu"}. Every distinct label set is a new series.
Data model and architecture
Read more · 137 words
| Store | Holds | Notes |
|---|---|---|
| Series index | label to series ids (an inverted index) | host="web-17" finds its series fast |
| Head | Newest 2 hours of samples per series, in memory | Backed by a write-ahead log |
| Blocks | Immutable compressed chunks per 2 hours | Timestamps as delta of deltas, values XORed |
| Rollups | min, max, sum, count per minute or hour | Same layout, coarser |
Never put an unbounded value in a label. user_id or request_id multiplies series by millions and breaks the index, memory and bill.
Samples flow left to right into storage; reads and alerts pull from storage. in the middle decouples the two speeds. Play A sample's journey, Dashboard query, Alert fires and Ingest burst.
Tap any box to see what it is and what it talks to.
- Client
- API gateway
- Queue
- Worker
- Database
- Object storage
- Service
- External API
Text description
- Every 10 seconds the agent sends one batch: about 100 samples of cpu, memory, requests and latency, each with labels such as host and service.
- The gateway checks the token and the format, then publishes to Kafka, partitioned by series, so a series always goes to the same writer.
- A writer consumes the batch. Kafka keeps the data until the writer confirms, so a crash replays instead of losing samples.
- The writer appends to the write-ahead log and to the in-memory head. Roughly 1 million samples a second arrive across all writers.
- Every two hours the head is compressed to about 1.4 bytes a sample and written to block storage as an immutable block.
Ingest and storage
Each agent batches about 100 samples every 10 seconds, with a bounded buffer that drops the oldest samples if the backend is down.
Read more · 100 words
, partitioned by series, absorbs bursts. Writers append to a and an in-memory head.
Neighbouring samples look alike. Timestamps follow a schedule, so the delta of deltas is nearly always 0: about 1 bit. Values change slowly, so XOR leaves few set bits. A 16-byte sample shrinks to about 1.4 bytes.
Rollups and queries
Nobody needs 10-second data from last year.
Read more · 75 words
Keep raw samples for 15 days, then 1-minute and 1-hour buckets of min, max, sum, count: the max shows a spike an average would hide. Queries use the coarsest tier that fits the chart.
Percentiles cannot be averaged. Store a histogram per bucket and add histograms to get any window's .
Alerting
An alert rule is a small state machine: inactive, pending, firing once the condition has held for the whole for-duration, then resolved.
Read more · 26 words
A blip never fires. Missing data counts as bad, so a dead host cannot look healthy. The notifier groups alerts, so 30 hosts make one page.
- Service
- Database
- External API
- Client
Text description
- Evaluator → Time-series DB: avg cpu 1m > 90%?
- Time-series DB replies to Evaluator: yes (95%)
- Evaluator → Time-series DB: 30 s later: still > 90%?
- Time-series DB replies to Evaluator: no (41%)
Step-by-step answer
Define the data
A sample is a timestamp, a value and a series identity (name plus labels). Everything else follows from series count and sample rate.
Size it
10M series every 10 seconds is 1M samples a second: 1.38 TB a day raw, about 118 GB compressed. Say which dominates: writes.
Make ingestion durable and elastic
Batching agents with bounded buffers, a gateway, partitioned by series. Writers can lag or restart without losing data.
Store for time
An in-memory head with a , flushed to immutable compressed blocks. Delta-of-delta timestamps and XOR values.
Control cardinality
Quotas per team, limits on label values, alerts when series counts jump. Cardinality is the real cost driver.
Downsample for long retention
Raw for 15 days, then min, max, sum and count per minute and per hour. Histograms for percentiles.
Alert without noise
State machine with a for-duration, grouped pages, repeat suppression, silences for maintenance, missing-data rules.
Monitor the monitor
A small independent system watches the pipeline: lag, ingest rate, and a alert that must always fire.
Implementation
Tabs: the agent's batching exporter, compression bit counters, with histograms, and the alert state machine with a notifier.
Read more · 108 words
- The batcher test covers size and time flushes, retry , and dropping the oldest samples when the buffer is full.
- The Gorilla test shows a regular schedule costs about 1 bit a timestamp, and reproduces the 1.38 TB and 10.6 TB sums.
- The rollup test shows averaging two values is wrong, and adding histograms is right.
// The agent's exporter: collect samples, send them in batches, never block the host, never grow without limit.
// - Flush when the batch is big enough (size) OR the oldest sample has waited long enough (time).
// - If a send fails, keep the data and retry later with exponential backoff.
// - If the buffer is full (the backend is down for long), drop the OLDEST samples: fresh data matters more than old.
// Time is passed in, so tests need no timers.
export class Batcher {
#buf = [];
#oldestAt = null;
#retryAt = 0;
#attempts = 0;
constructor({ send, maxItems = 500, maxWaitMs = 1000, maxBuffer = 10_000, retryBaseMs = 200, retryMaxMs = 30_000 }) {
Object.assign(this, { send, maxItems, maxWaitMs, maxBuffer, retryBaseMs, retryMaxMs });
this.dropped = 0;
this.sent = 0;
}
get pending() { return this.#buf.length; }
add(sample, now) {
if (this.#buf.length >= this.maxBuffer) { this.#buf.shift(); this.dropped++; }
if (this.#buf.length === 0) this.#oldestAt = now;
this.#buf.push(sample);
return this.#due(now) ? this.flush(now) : Promise.resolve(false);
}
// Call this from a timer (every 100 ms or so) so a quiet agent still sends what it has.
tick(now) {
return this.#due(now) ? this.flush(now) : Promise.resolve(false);
}
#due(now) {
if (this.#buf.length === 0 || now < this.#retryAt) return false;
return this.#buf.length >= this.maxItems || now - this.#oldestAt >= this.maxWaitMs;
}
async flush(now) {
const batch = this.#buf.splice(0, this.maxItems);
if (batch.length === 0) return false;
try {
await this.send(batch);
this.sent += batch.length;
this.#attempts = 0;
this.#oldestAt = this.#buf.length ? now : null;
return true;
} catch {
this.#buf.unshift(...batch); // keep it for next time
this.#attempts++;
this.#retryAt = now + Math.min(this.retryMaxMs, this.retryBaseMs * 2 ** (this.#attempts - 1));
return false;
}
}
}Failure modes and trade-offs
Read more · 198 words
| What breaks | Effect | Mitigation |
|---|---|---|
| Backend unreachable | Agents buffer, then drop | Bounded buffer, drop oldest, retry with backoff |
| Burst after an outage | Writers lag | Kafka absorbs; autoscale writers; alert on lag |
| Cardinality explosion | Memory and cost spike | Per-team series quotas; reject unbounded labels |
| Writer or head node crash | Recent data at risk | Write-ahead log replay; replicate the head |
| Alert evaluator dies | Silent failures | Two evaluators; a canary alert that must fire |
| The monitor is down with the system | Blind during an outage | Separate infrastructure and a second basic monitor |
Choices to defend:
| Choice | Gain | Cost |
|---|---|---|
| Push | Works for short-lived jobs and NAT | Backend must protect itself from floods |
| Pull | Easy health view and control | Needs service discovery; hard for batch jobs |
| Rollups | Years of history cheaply | Lose raw detail; percentiles need histograms |
| Long for-duration | Fewer false pages | Slower detection |
Follow-up questions
Why Kafka in the middle?
Bursts queue instead of dropping, and writers can restart safely.
How do you shard the time-series database?
By series hash across writers; queries and merge. Time blocks let old data drop cheaply.
How do you monitor the monitoring system?
A small separate stack elsewhere, alerting on lag and a .
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.