← System design

Infrastructure

Design Metrics & Monitoring

Every service in a company emits numbers: request counts, latencies, queue depths, CPU, memory. A metrics platform collects them all, keeps them cheaply for months, and lets an engineer at 3 a.m. ask "what changed in the last ten minutes, and where?" and get an answer before the incident gets worse. It also has to be the most reliable system in the building, because it is the tool people use when everything else is on fire.
Core: compressed time series blocks + label index40M samples per secondAlert within 60 sCardinality is the real limit

01

What the platform must do

02

What the platform must do

Functional
  • Collect counters, gauges and histograms from hosts, containers and applications, each sample identified by a metric name plus labels such as service, region and status.
  • Query with a functional language: rates over windows, sums grouped by label, percentiles from histograms.
  • Dashboards that refresh every 15 to 30 seconds.
  • Alert rules evaluated continuously, routed to paging, chat or email with deduplication and silencing.
  • Raw resolution for 15 days, downsampled data for 13 months.
  • Multiple teams share the platform, each with its own quota.
Non-Functional
  • A sample becomes queryable within about 30 seconds of being produced.
  • Alerts fire within 60 seconds of a condition becoming true.
  • Dashboard queries over the last hour return in under 1 second at p95.
  • The write path keeps working during partial outages; losing a few seconds of samples is tolerable, losing an hour is not.
  • The monitoring system must not share failure domains with what it monitors.
Ask about cardinality, not hosts

Host count is the number people quote, but the cost driver is the number of unique series, every distinct combination of metric name and labels. Ask how many there are, how fast they churn (containers come and go), and whether anyone is allowed to use user IDs or request IDs as labels. The answer to the last one should be no, and you should say so.

02

Sizing the load

03

Sizing the load

Assume 400,000 monitored hosts and containers, each exposing about 1,000 series, collected every 10 seconds. Container churn replaces a third of all series every day.

QuantityWorkingResult
Active series400K targets x 1,000 series400M
Ingest rate400M / 10 s interval40M samples / s
Raw sample size8 B timestamp + 8 B float16 B uncompressed
Compressed sample sizedelta-of-delta timestamps, XOR floatsabout 1.4 B
Raw data per day40M x 86,400 x 1.4 Babout 4.8 TB
Raw retention15 days x 4.8 TB x 2 replicasabout 145 TB
Downsampled retention5 min rollups, 5 aggregates, 13 monthsabout 60 TB in object storage
Series churnone third of 400M per dayabout 130M new series / day
Index memory400M series x about 1 KB of labels and postingsabout 400 GB across the cluster
Alert rules60K rules evaluated every 30 s2,000 rule queries / s
Compression changes everything

Without compression, one day of raw samples is about 55 TB. With time series encoding it drops to under 5 TB. A good encoding is the difference between a dozen storage nodes and a hundred, which is why the storage format gets most of the deep dive.

Index memory is the hidden cost

The sample data compresses well, but the inverted index over labels does not. With heavy churn it is often the first thing to run out of memory, long before disks fill.

03

API design

04

API design

POST/v1/writePush a batch of samples
// Protobuf, snappy compressed; one request carries about 2,000 samples { "series": [{ "labels": { "__name__": "http_requests_total", "service": "checkout", "code": "500", "pod": "checkout-7f9c" }, "samples": [[1790851200000, 48213], [1790851210000, 48260]] }] } // 204 on success, 429 with Retry-After if the tenant is over its series quota
GET/v1/query_range?q=...&start=...&end=...&step=30sQuery a time range
// q = sum by (region) (rate(http_requests_total{service="checkout",code=~"5.."}[5m])) { "result": [ { "labels": { "region": "eu-west" }, "points": [[1790851200, 3.4], [1790851230, 3.9]] } ], "stats": { "series_scanned": 812, "cache": "partial" } }
POST/v1/rulesCreate an alert rule
{ "name": "CheckoutErrorRateHigh", "expr": "sum(rate(http_requests_total{service=\"checkout\",code=~\"5..\"}[5m])) / sum(rate(http_requests_total{service=\"checkout\"}[5m])) > 0.02", "for": "3m", // must hold for 3 minutes before firing "severity": "page", "route": "team-payments-oncall" }
GET/v1/series?match=up{job="api"}Explore label values
// Used by dashboard pickers; answered from the label index only, never from sample data

04

Architecture overview

05

Architecture overview

Collection, storage, query and alertingDiagram
flowchart LR
	T["Hosts and apps"] --> AG["Local collector agent"]
	AG --> GW["Ingest gateway"]
	GW --> K[["Kafka: samples by series hash"]]
	K --> IG["Ingesters: in-memory head blocks"]
	IG --> WAL[("Local WAL")]
	IG --> OBJ[("Object store: compacted blocks")]
	CMP["Compactor and downsampler"] --> OBJ
	Q["Query frontend"] --> QC[("Results cache")]
	Q --> IG
	Q --> SG["Store gateways"]
	SG --> OBJ
	R["Rule evaluator"] --> Q
	R --> AM["Alert manager"]
	AM --> N["Pager, chat, email"]

A small agent on each host scrapes local endpoints every 10 seconds, adds host labels and ships batches to an ingest gateway. Pushing from an agent rather than pulling from a central server means the platform does not need network reach into every subnet, and the agent can buffer through a short outage.

The gateway authenticates the tenant, enforces quotas and writes batches into Kafka, partitioned by a hash of the series labels so all samples for one series land in order on one partition. Ingesters consume those partitions and keep the most recent two hours of each series in memory as compressed chunks, writing a local write-ahead log for crash recovery. Every two hours, an ingester cuts its in-memory data into an immutable block and uploads it to object storage.

Queries go through a frontend that splits long ranges by day, serves repeated pieces from a cache, and fans out to two kinds of readers: ingesters for the most recent hours, and store gateways that read blocks from object storage for everything older. A compactor merges small blocks into large ones and produces downsampled versions. The rule evaluator is just another query client that runs every alert expression on a schedule and sends firing alerts to an alert manager, which deduplicates, groups and routes them.

Kafka as a shock absorber

If ingesters slow down or restart, samples wait in Kafka instead of being dropped at the edge. It also lets you replay a partition into a fresh ingester after a crash.

Ingesters

The only stateful hot tier. Each owns a set of partitions, holds two hours of data in memory and is replicated twice so a single failure does not leave a hole in recent graphs.

Object storage

Long-term blocks are immutable files, so cheap storage with high durability fits perfectly. Readers cache block indexes and hot chunks on local disk.

Alerting

Rule evaluation reads the same data as dashboards. The alert manager handles what humans care about: grouping, silences, inhibition and escalation.

05

Deep dive: storage, the label index and cardinality

06

Deep dive: storage, the label index and cardinality

Three questions

How do you store tens of millions of samples a second in a few bytes each? How do you find the right 800 series out of 400 million in milliseconds? And how do you stop one bad label from taking the whole platform down? The storage format, the index and the cardinality guards answer them in that order.

Compressing samples

Time series data is extremely regular, and the encoding exploits that. Timestamps for a series arrive at nearly fixed intervals, so store the first timestamp, then the first delta, then only the difference between consecutive deltas. For a perfectly steady 10-second scrape that difference is zero and costs a single bit. Small jitter fits in a handful of bits.

Values change slowly too. XOR each float with the previous one: identical values give zero (one bit), and similar values share their sign, exponent and leading mantissa bits, so you store only the meaningful middle bits plus a short header. This scheme, popularised by Facebook's Gorilla paper, brings a 16-byte sample to roughly 1.3 to 1.5 bytes on typical infrastructure data.

Samples are grouped into chunks of about 120 points per series. A chunk is the unit you compress, cache and read, which means a query for one hour of one series touches only a few chunks.

Life of a sample through storageDiagram
flowchart TD
	S["Sample arrives at ingester"] --> H["Append to head chunk for its series"]
	H --> W["Append to write-ahead log"]
	H --> F{"Chunk has 120 points?"}
	F -->|yes| C["Seal chunk, start a new one"]
	F -->|no| H
	C --> B{"Two hours elapsed?"}
	B -->|yes| BL["Write block: chunks, label index, metadata"]
	BL --> UP["Upload block to object store"]
	UP --> CP["Compactor merges blocks into 1 day and 2 weeks"]
	CP --> DS["Downsample to 5 min and 1 h rollups"]

Finding series fast

Every block carries an inverted index: for each label pair such as service="checkout", a sorted list of the series IDs that have it. A query selector is a set of label matchers, so the engine fetches each matching postings list and intersects them. Because the lists are sorted, intersection is a linear merge. Regex matchers expand to the union of the matching label values first. The index also stores label names and values on their own, which is what dashboard dropdowns query.

The most expensive pattern is a matcher that hits millions of series, such as every pod of every service. Query planning should apply the most selective matcher first and refuse queries that would scan more than a set limit of series, returning a clear error instead of dragging the cluster down.

Cardinality is the real capacity limit

Each new label value creates a new series, and each series costs memory for its head chunk and index entry whether it receives one sample or a million. A single developer who adds user_id as a label to a request counter can create tens of millions of series in an hour. This is the most common way metrics platforms fall over.

Defend in layers. Per-tenant limits on active series, enforced at the gateway, reject new series once a tenant passes its quota while still accepting samples for existing ones. Per-metric limits catch a single runaway metric. Relabeling rules in the agent drop known bad labels before they leave the host. And a cardinality explorer shows teams which metrics and labels cost the most, so they can fix the cause.

Downsampling and long ranges

A 90-day graph at 10-second resolution would need almost 780,000 points per series, far more than a screen can show. The compactor writes rollups at 5 minutes and 1 hour, storing count, sum, min, max and last per window so that averages, rates and extremes can be computed correctly. The query engine picks the coarsest resolution that still gives enough points for the requested step.

Alert evaluation

Rules are sharded across evaluators by hash, and each one runs its rules on a fixed interval. A rule with a for clause only fires after the condition has been true for that long, which removes flapping from single bad scrapes. The evaluator also needs to alert on absence: a rule like "this service reported nothing for five minutes" catches a dead agent that would otherwise look like a quiet, healthy system.

A strong interview answer

"Agents push batches to a gateway that enforces series quotas, then Kafka partitions by series hash. Ingesters keep two hours in memory using delta-of-delta timestamps and XOR floats, about 1.4 bytes a sample, and flush immutable blocks with an inverted label index to object storage. Queries merge recent data from ingesters with older blocks, and long ranges read rollups. Alert rules run on the same query path, and the alerting stack is deployed separately from what it watches."

06

Mistakes to avoid

07

Mistakes to avoid

🚫
Using request IDs or user IDs as label values

Every value becomes its own series with its own memory. A busy service can create millions of series an hour, and ingesters run out of memory for everyone.

✓ Better: keep labels to bounded sets like service, region and status class; put high-cardinality details in logs or traces.
🚫
Storing samples in a general relational table

A row per sample with a B-tree index costs tens of bytes per point and collapses under tens of millions of inserts a second.

✓ Better: a purpose-built time series format with compressed chunks and an append-only head.
🚫
Running alerting on the same cluster it monitors

When that cluster has a network or storage problem, both the application and its alerts go silent together.

✓ Better: run a separate, small alerting stack in another failure domain, plus a heartbeat alert that fires if the alerting pipeline itself stops.
🚫
Computing p99 by averaging per-host p99s

Averages of percentiles are mathematically meaningless and hide the slow hosts you are looking for.

✓ Better: export histograms with fixed buckets, sum buckets across hosts, then compute the percentile from the merged histogram.
🚫
Keeping full resolution forever

Storage grows linearly and year-long queries scan billions of points to draw a few hundred pixels.

✓ Better: downsample after two weeks and let the query engine choose the resolution from the step.

07

Decisions and what they cost

08

Decisions and what they cost

Chosen
Agents push to a gateway

Works across networks and accounts, buffers locally during outages, and gives a central place to enforce quotas. The cost is an agent on every host and less direct knowledge of which targets should exist.

Fits a multi-tenant platform
Alternative
Central servers scrape targets

The pull model makes "target is down" a first-class signal and keeps applications simple. It needs service discovery and network reach to every target, which is harder across many accounts.

Great inside one cluster
Chosen
Hot data in ingesters, history in object storage

Recent data, which most queries want, is in memory. Old blocks are cheap to keep for a year. The price is a two-tier read path that has to merge and deduplicate results.

Cheap and fast where it matters
Rejected
Everything on local SSD in one database

Simpler reads, but storage and compute scale together, so keeping a year means paying for a year of SSD and rebalancing terabytes when nodes change.

Expensive at this size
Chosen
Accept small gaps, never block writers

When ingesters are overloaded, the gateway sheds load per tenant rather than slowing agents. A few seconds of missing data is visible but harmless.

Availability first
Rejected
Exactly-once delivery of every sample

Monitoring data loses value quickly, and the coordination needed for exactly-once would slow the write path for a guarantee nobody uses.

Wrong guarantee
Chosen
Fixed-bucket histograms

Bucket counts add across hosts, so you can compute any percentile for any group afterwards. Precision depends on bucket boundaries, which must be picked per metric.

Mergeable
Alternative
Client-side summaries

Exact percentiles per process with no bucket tuning, but they cannot be combined across processes. Useful for a single service, not for fleet views.

Narrow use

08

How to handle the interview

09

How to handle the interview

1

Turn hosts into series, then samples per second. Do this arithmetic first. It sets the scale for every other choice and shows you know series count is the real unit.

2

Explain the compression trick in two sentences. Delta-of-delta for timestamps, XOR for values, about 1.4 bytes a sample. Then show what that does to the storage bill.

3

Bring up cardinality before you are asked. Describe the user_id label incident and the quota that prevents it.

4

Separate hot and cold storage. Show the read path merging ingesters with object storage blocks, and explain downsampling for long ranges.

5

Ask who monitors the monitor. A separate alerting stack and a dead man's switch heartbeat show operational maturity.

6

Know why averaged percentiles are wrong. Interviewers often test this. Answer with mergeable histograms.

7

Talk about alert quality. A for duration, grouping and routing prevent pager fatigue. A design that pages on every blip is not finished.

09

Failure modes

10

Failure modes

⏱️
Ingesters fall behind Kafka

A slow disk or a bad deploy makes consumers lag by fifteen minutes. Dashboards show flat lines and alerts evaluate on stale data, so they stay quiet during a real outage.

Alert on consumer lag directly from a separate system, make rule evaluators skip and flag evaluations when data is older than a threshold, and scale ingesters on lag.
💥
A cardinality explosion from one deploy

A new release labels a metric with the full request path including IDs. Active series double in twenty minutes and ingester memory hits its limit.

Per-tenant and per-metric series limits at the gateway reject new series past the quota, while existing ones keep flowing. The tenant gets a clear error and a pointer to the offending metric.
🔕
The alert evaluator stops silently

An evaluator crashes in a loop. No alerts fire, which looks exactly like everything being healthy.

Have the alerting pipeline emit a constant "always firing" heartbeat to an external service that pages if the heartbeat stops arriving.
🐢
A one-year query across every pod

Someone opens a dashboard with a year range and no filters. Store gateways load thousands of blocks and memory pressure slows every other query.

Enforce limits on series scanned and bytes fetched per query, use downsampled data for long ranges, split queries by day in the frontend, and give each tenant its own concurrency budget.
🌊
Agents flush their buffers at once

A network partition heals and 400,000 agents send their buffered samples together, several times the normal rate.

Agents retry with jittered backoff and send buffered data at a capped rate. The gateway returns 429 with Retry-After instead of falling over.
🗄️
The compactor stops and blocks pile up

Compaction fails for days. Object storage fills with thousands of small blocks and long queries slow down because each must open many files.

Alert on the number of uncompacted blocks per tenant and on compactor progress, and make compaction jobs restartable and idempotent.

10

Growing the design in stages

12

Growing the design in stages

Stage 1: one team, a few hundred hosts
A single Prometheus server

One server scrapes everything, stores two weeks on local disk and runs the alert rules. Fast, simple and entirely sufficient at this size.

Stage 2: many teams
One server per team or cluster

Scaling by splitting works for a while, but cross-cluster views need federation and nobody can see the whole fleet in one query.

Stage 3: long retention needed
Blocks in object storage

Local servers upload their blocks to object storage, and a query layer reads across them for a global view and a year of history.

Stage 4: hundreds of millions of series
Horizontally scaled ingest

Agents push through a gateway and Kafka to a sharded ingester tier with replication, quotas and a query frontend. The single scraping server per team is retired.

Stage 5: a company-wide platform
Tenancy, cost controls and correlation

Per-team cost reports, cardinality explorers and links from metrics to traces and logs turn the system into a product other engineers rely on daily.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.