BackendHard45 min

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.

Say it in 30 seconds
  • 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.

0 of 0 sections ticked
30-second answer

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).

QuantityCalculationResult
Series100K hosts × 100 metrics10M
Samples10M ÷ 10 s1M/s
Raw size1M × 16 B × 86,400 s1.38 TB/day
Compressed1M × 1.37 B × 86,400 s≈ 118 GB/day
Raw, 15 days118 GB × 151.8 TB
1-minute rollups, 90 days10M × 1,440 × 6 B × 907.8 TB
1-hour rollups, 2 years10M × 24 × 6 B × 7301.05 TB
Dashboard queries10K open × 10 panels ÷ 30 s≈ 3,300/s
Alert queries10K 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:

CallNotes
POST /v1/writeBatch of { labels, samples: [{ t, v }] }, compressed
GET /v1/query_rangequery, start, end, step → series of points
PUT /v1/rulesAlert rule: query, threshold, for, labels, route
GET /v1/alertsPending 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
StoreHoldsNotes
Series indexlabel to series ids (an inverted index)host="web-17" finds its series fast
HeadNewest 2 hours of samples per series, in memoryBacked by a write-ahead log
BlocksImmutable compressed chunks per 2 hoursTimestamps as delta of deltas, values XORed
Rollupsmin, max, sum, count per minute or hourSame 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.

Press Play to watch it run · 5 steps

Agents on every host batch samples and push them through an ingest gateway into Kafka, which absorbs bursts. Writers append to a time-series database that keeps a recent in-memory head with a write-ahead log and flushes compressed blocks to object storage. A rollup job keeps coarser copies for long retention, the query service reads the right tier for dashboards, and an alert evaluator runs rules and pages through a notifier.

Metrics and monitoring architectureAgents on every host batch samples and push them through an ingest gateway into Kafka, which absorbs bursts. Writers append to a time-series database that keeps a recent in-memory head with a write-ahead log and flushes compressed blocks to object storage. A rollup job keeps coarser copies for long retention, the query service reads the right tier for dashboards, and an alert evaluator runs rules and pages through a notifier.batch / 10 spublishconsumeappendflush blocksPromQL-stylerange querydownsamplerule queriespageA small process on every host. It reads about 100 metrics every 10 seconds, batches them and pushes. If the backend is down it keeps a bounded buffer and drops the oldest samples.Host agents100K hostsIngest gatewayauth + validatePartitioned by series id so all samples of one series land in order on one writer. It absorbs bursts, and lets a writer fall behind or restart without losing data.Kafkaby series hashTSDB writersappendThe newest two hours live in memory with a write-ahead log. Older data is cut into compressed blocks. Timestamps and values compress to about 1.4 bytes a sample.Time-series DBhead 2 h + WALBlock storagecompressed blocksQuery servicepicks the tierDashboards10K openAlert evaluator10K rulesNotifierpage, SlackRollup job1 min, 1 h tiers

Tap any box to see what it is and what it talks to.

  • Client
  • API gateway
  • Queue
  • Worker
  • Database
  • Object storage
  • Service
  • External API
Open in playground
Text description
  1. 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.
  2. The gateway checks the token and the format, then publishes to Kafka, partitioned by series, so a series always goes to the same writer.
  3. A writer consumes the batch. Kafka keeps the data until the writer confirms, so a crash replays instead of losing samples.
  4. The writer appends to the write-ahead log and to the in-memory head. Roughly 1 million samples a second arrive across all writers.
  5. 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.

Press Play to watch it run · 4 steps

The evaluator checks a rule every 30 seconds. A condition must hold for the whole for-duration before it fires, which filters blips. When it fires, the notifier groups related alerts into one page. When the metric recovers, the alert resolves.

From a spike to a pageThe evaluator checks a rule every 30 seconds. A condition must hold for the whole for-duration before it fires, which filters blips. When it fires, the notifier groups related alerts into one page. When the metric recovers, the alert resolves.avg cpu 1m > 90%?yes (95%)State: pending, the 5 minute timer startsalt [blip]30 s later: still > 90%?no (41%)Back to inactive. Nobody was paged
  • Service
  • Database
  • External API
  • Client
Text description
  1. Evaluator → Time-series DB: avg cpu 1m > 90%?
  2. Time-series DB replies to Evaluator: yes (95%)
  3. Evaluator → Time-series DB: 30 s later: still > 90%?
  4. Time-series DB replies to Evaluator: no (41%)

Step-by-step answer

  1. 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.

  2. 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.

  3. Make ingestion durable and elastic

    Batching agents with bounded buffers, a gateway, partitioned by series. Writers can lag or restart without losing data.

  4. Store for time

    An in-memory head with a , flushed to immutable compressed blocks. Delta-of-delta timestamps and XOR values.

  5. Control cardinality

    Quotas per team, limits on label values, alerts when series counts jump. Cardinality is the real cost driver.

  6. Downsample for long retention

    Raw for 15 days, then min, max, sum and count per minute and per hour. Histograms for percentiles.

  7. Alert without noise

    State machine with a for-duration, grouped pages, repeat suppression, silences for maintenance, missing-data rules.

  8. 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 breaksEffectMitigation
Backend unreachableAgents buffer, then dropBounded buffer, drop oldest, retry with backoff
Burst after an outageWriters lagKafka absorbs; autoscale writers; alert on lag
Cardinality explosionMemory and cost spikePer-team series quotas; reject unbounded labels
Writer or head node crashRecent data at riskWrite-ahead log replay; replicate the head
Alert evaluator diesSilent failuresTwo evaluators; a canary alert that must fire
The monitor is down with the systemBlind during an outageSeparate infrastructure and a second basic monitor

Choices to defend:

ChoiceGainCost
PushWorks for short-lived jobs and NATBackend must protect itself from floods
PullEasy health view and controlNeeds service discovery; hard for batch jobs
RollupsYears of history cheaplyLose raw detail; percentiles need histograms
Long for-durationFewer false pagesSlower 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 3

Score 2 out of 3 or better to mark this page complete.

1. 100K hosts each report 100 metrics every 10 seconds. How many samples per second, and how much a day raw at 16 bytes each?