Infrastructure
Design Metrics & Monitoring
01
Requirements
Requirements
- Agents on every host collect system + custom app metrics (CPU, mem, disk, request latency, queue depth, etc.)
- Batch ingest via POST /metrics/ingest — each payload: metric_name, tags, value, timestamp
- Flexible query language: avg(cpu.usage{host=X, region=us-east}) over arbitrary time ranges
- Dashboard builder: users compose panels of queries, refresh live every 10-30 seconds
- Alerting: threshold rules (CPU > 90% for 5 min) and anomaly detection (rolling mean + 3 sigma)
- Notification channels: PagerDuty, Slack, email, webhooks on alert fire/resolve
- Tag-based filtering: every metric has key-value tags (host, region, env, service) for slicing
- Downsampling: automatic resolution reduction for older data to bound storage growth
- Ingest throughput: 100M data points/sec sustained
- Query latency: < 1 second for 1-hour range over 10K series
- Cardinality: handle 10M unique time series without OOM
- Retention: 7 days full resolution, 30 days at 5-min, 1 year at 1-hour
- Alert evaluation latency < 60 seconds end-to-end (metric emitted to alert fired)
- 99.9% ingest availability — dropping metrics = blind spot during incidents
- Horizontally scalable: add writer/reader nodes to handle more series and queries
- Multi-tenant isolation: one org's cardinality bomb must not affect other tenants
- Durable: no data loss on single-node failure; replicated writes across availability zones
02
Scale Estimation
Scale Estimation
03
API Design
API Design
Batch ingest. Body: [{metric_name, tags: {host, region, ...}, value: float, timestamp: epoch_sec}, ...]. Agents batch 100-500 samples per request. Returns {accepted: N}. Authenticated via API key per org.
Time-series query. Expression uses PromQL-like syntax: aggregation functions, label matchers, rate/increase transforms. Returns {series: [{tags, datapoints: [[ts, val], ...]}]}.
Create alert rule. Body: {name, expr: "avg(cpu.usage{env=prod}) > 90", for: "5m", severity: "critical", notify: ["pagerduty://team-infra", "slack://#alerts"]}. Supports threshold + anomaly detection rules.
Fetch dashboard definition. Returns {panels: [{title, queries: [...], viz_type: "line|heatmap|gauge"}]}. Frontend renders and auto-refreshes every 10-30 sec.
Alert state history. Returns [{timestamp, state: "ok|pending|firing|resolved", value}]. Useful for post-incident review: "when did the alert first fire? how long was it pending?"
Search available metric names and their tag keys. Returns [{metric_name, type: "gauge|counter|histogram", tags: ["host","region",...], series_count}]. Powers autocomplete in dashboard builder.
04
Architecture
Architecture
Two primary data paths: ingest path (agents push metrics through a gateway into Kafka, then TSDB writers compress and store in time-bucketed blocks) and query path (user queries fan out to TSDB readers across time shards, merge results). A separate alert evaluator runs on a cron loop, executing queries and firing notifications on threshold breach.
Ingest path detail: Each agent batches 100-500 samples and POSTs to the ingest gateway. The gateway validates the payload (metric name format, tag count limits, cardinality checks), authenticates via API key, and publishes to Kafka. Kafka is partitioned by hash(metric_name + sorted_tags) so all samples for the same series land on the same partition — this gives TSDB writers write locality. Writers consume from Kafka, buffer in memory for the current time block (2 hours), and periodically flush compressed chunks to disk.
Query path detail: The query gateway parses the expression, resolves label matchers against the metadata index to find matching series IDs, determines which time blocks overlap the requested time range, and fans out read requests to TSDB readers that own those blocks. Each reader decompresses the relevant chunks, computes partial aggregates, and streams results back. The gateway merges partial results and returns the final answer. For very wide time ranges, the gateway automatically routes to downsampled data.
Alert path detail: The alert evaluator is a stateful cron service that reads rules from a Postgres-backed rules DB. Each evaluation cycle, it partitions rules across evaluator instances (sharded by rule ID hash for horizontal scaling). Each instance runs its assigned rules as TSDB queries, tracks state transitions (ok/pending/firing/resolved), and publishes firing alerts to the notification service. The notification service handles deduplication, grouping (multiple related alerts into one page), and channel routing.
05
Deep Dive — TSDB Storage, Cardinality & Alerting
Deep Dive — TSDB Storage, Cardinality & Alerting
(a) TSDB storage model. Data is partitioned by time into blocks — typically 2-hour windows. Within each block, series are stored in columnar format with aggressive compression:
-- Gorilla encoding (Facebook, 2015):
-- Timestamps: delta-of-delta encoding
-- t0=1000, t1=1010, t2=1020, t3=1030
-- deltas: 10, 10, 10 → delta-of-delta: 0, 0, 0 → 2 bits each
-- Values: XOR encoding for IEEE 754 floats
-- v0=72.5, v1=72.6 → XOR = small diff → encode leading/trailing zeros
-- Result: ~1.37 bytes per data point (vs 16 bytes raw)
Compaction merges small blocks into larger ones periodically — for example, six 2-hour blocks merge into one 12-hour block with a single sorted index. This reduces the number of blocks the query engine must open for wide time ranges. Downsampling reduces resolution for older data: 1-min resolution for 7 days, 5-min for 30 days, 1-hour for 1 year. Downsampling pre-computes min/max/sum/count per interval so that queries on old data can still compute accurate aggregates. This keeps storage bounded while preserving long-term trends for capacity planning and SLA reporting.
(b) Cardinality explosion. Each unique combination of (metric_name, tag_key=tag_value) creates one time series. If someone adds user_id as a tag with 1M unique values — instant 1M series per metric. This kills ingestion rate, bloats the metadata index, and causes OOM on the TSDB.
// Bad: http.requests{user_id=U123456} → 1M series
// Good: http.requests{endpoint=/api/users, status=200} → ~500 series
// Solution: cardinality limit per metric (e.g., max 10K series)
// + tag-value sampling for high-cardinality dimensions
// + reject metrics that exceed cardinality budget at ingest gateway
(c) Alert evaluation loop. Every 15-60 seconds, the alert evaluator reads all active rules from the rules DB, executes each as a TSDB query, and compares the result against the threshold. For anomaly detection: compute rolling mean + standard deviation over a training window (e.g., same hour last 7 days), alert if current value exceeds mean + 3 sigma. The evaluator maintains state for each rule: pending (condition met but not yet for the required duration), firing (condition sustained past the for duration), and resolved (condition no longer met). State transitions trigger notifications.
// Alert evaluation pseudocode (runs every eval_interval):
// for each rule in active_rules:
// result = tsdb.query(rule.expr, now - rule.window, now)
// if result > rule.threshold:
// if rule.state == "pending" && elapsed > rule.for_duration:
// rule.state = "firing"
// notify(rule.channels, "FIRING", result)
// elif rule.state == "ok":
// rule.state = "pending"; rule.pending_since = now
// else:
// if rule.state == "firing":
// notify(rule.channels, "RESOLVED", result)
// rule.state = "ok"
sequenceDiagram
participant A as Agent (host)
participant IG as Ingest Gateway
participant K as Kafka
participant W as TSDB Writer
participant T as TSDB Storage
participant QG as Query Gateway
participant AE as Alert Evaluator
participant N as Notification svc
A->>IG: POST /metrics/ingest (batch)
IG->>K: publish to partition(metric_hash)
K->>W: consume batch
W->>T: encode (Gorilla) + write to 2h block
Note over T: time-bucketed columnar storage
AE->>QG: query "avg(cpu.usage{env=prod})"
QG->>T: fan-out to 1h of time blocks
T-->>QG: compressed chunks
QG-->>AE: avg = 94.2%
AE->>AE: 94.2 > threshold 90 for 5m?
AE->>N: FIRE critical alert
N->>N: PagerDuty + Slack + email
(d) Query fan-out and merge. A query like avg(cpu.usage{env=prod})[1h] requires reading data from multiple time blocks (e.g., if block size is 2 hours, a 1-hour query touches 1 block). But across 10K hosts, each block has 10K series entries. The query gateway identifies relevant series via the metadata index (inverted index: tag=value maps to series IDs), then fans out read requests to TSDB readers that own the relevant blocks. Each reader decompresses its chunk, computes a partial aggregate, and returns it. The gateway merges partial results into the final answer.
(e) Metadata index design. The metadata index is an inverted index: for each tag key-value pair, it stores the set of series IDs that contain that tag. Lookups are intersections of posting lists — identical to a search engine. For cpu.usage{host=web-01, region=us-east}, intersect the posting list for host=web-01 with region=us-east to find matching series IDs. This index lives in memory for fast lookup and is persisted to disk for durability.
// Inverted index structure:
// metric_name="cpu.usage" → [series_1, series_2, ..., series_N]
// host="web-01" → [series_1, series_47, series_902]
// region="us-east" → [series_1, series_2, series_47, ...]
//
// Query: cpu.usage{host=web-01, region=us-east}
// → intersect([series_1,s_2,...], [s_1,s_47,s_902], [s_1,s_2,s_47,...])
// → [series_1] ← only series matching ALL labels
"Agents on every host batch metrics and POST to an ingest gateway. The gateway publishes to Kafka partitioned by metric hash. TSDB writers consume batches, encode with Gorilla compression (delta-of-delta timestamps, XOR floats — ~1.37 bytes/point), and write to 2-hour time-bucketed blocks. Queries fan out across relevant time blocks, decompress, aggregate, and return in <1 second. Alert evaluator runs on a 15-60 second cron, executes each rule as a TSDB query, and fires notifications via PagerDuty/Slack when thresholds are breached. Cardinality is bounded by per-metric limits at the ingest gateway."
06
Anti-patterns
Anti-patterns
Write amplification: each INSERT updates indexes, WAL, MVCC. No columnar compression. At 100M pts/sec you would need 1000+ Postgres nodes. Querying 1 hour of data scans billions of rows with no time-locality optimization. B-tree indexes on timestamp don't help — you still read full rows.
1M unique user_ids as a tag = 1M time series per metric. Metadata index OOMs. The inverted index (tag-value to series-ID mapping) grows unboundedly. Ingestion slows as every new data point creates a new series. Queries matching on that metric must scan millions of posting lists.
CPU spikes to 95% for 3 seconds, drops to 40%, spikes again. Raw threshold alert fires and resolves every 30 seconds — flapping. On-call engineer mutes the alert after the third page. When a real sustained incident happens, the muted alert never fires. Alert fatigue kills incident response.
for clause to require the condition to hold across multiple evaluation cycles. For anomaly detection, use rolling mean + 3 sigma over historical baselines (same hour, same day-of-week).07
Tradeoffs & Design Choices
Tradeoffs & Design Choices
- Push (Datadog) vs Pull (Prometheus). Push: agents POST metrics to a central gateway. Scales better for ephemeral containers (short-lived pods don't need to be discovered). Pull: monitoring server scrapes registered targets. Simpler for static fleets — no agent config needed, server controls scrape interval. Hybrid: Prometheus push-gateway for short-lived jobs.
- Pre-aggregation vs raw storage. Pre-agg (compute avg/p99 at ingest) saves storage and speeds queries, but loses the ability to re-aggregate differently later. Raw storage preserves flexibility — you can compute any aggregation at query time — but costs 10-100x more storage. Most systems store raw for recent data, pre-agg for older data.
- Purpose-built TSDB (Prometheus, VictoriaMetrics) vs general OLAP (ClickHouse). TSDB: optimized for time-range scans, Gorilla compression, built-in downsampling. OLAP: more flexible query language, handles higher cardinality, but less compression for regular time-series patterns. Datadog uses a custom TSDB; Uber Observability uses ClickHouse.
- Single global TSDB vs per-region shards. Global: simpler queries across regions. Per-region: lower ingest latency, data locality, but cross-region queries require fan-out and merge. Most production systems use per-region with a global query aggregator.
- Gorilla compression vs general-purpose (LZ4/Snappy). Gorilla achieves ~1.37 bytes/point for regular time series. LZ4 on raw floats gets ~4-5 bytes/point. Gorilla wins for uniform-interval numeric data; LZ4 wins for irregular or string data.
- Alert on metrics vs alert on logs. Metric alerts are cheap (evaluate one number against a threshold). Log-based alerts require full-text search per evaluation — 100x more expensive. Use metrics for known failure modes (CPU, latency, error rate); logs for unknown/exploratory debugging.
- In-memory hot tier vs disk-only. Keep the most recent block (current 2-hour window) in memory for fast writes and low-latency queries. Older blocks on SSD. Oldest blocks on object storage (S3). Tiered storage balances cost with query performance.
- PromQL vs SQL for metrics. PromQL is purpose-built for time-series:
rate(),histogram_quantile(), label-based selection are first-class. SQL requires verbose CTEs and window functions for the same operations. But SQL is universally known — ClickHouse and TimescaleDB bet on SQL familiarity lowering the learning curve. Trade-off: expressiveness vs accessibility. - Separate alert evaluator vs co-located with query engine. Separate process: can scale independently, failure doesn't affect user queries. Co-located: lower latency (no network hop for rule queries), simpler deployment. Most production systems use a separate evaluator for isolation.
08
Interview Tips
Interview Tips
- Start with the data model. "A time series is identified by (metric_name + set of tag key-value pairs). Each series has a stream of (timestamp, float_value) tuples." This grounds everything.
- Name Gorilla encoding explicitly. "Delta-of-delta for timestamps, XOR for float values — from Facebook's 2015 paper. Gets ~1.37 bytes/point vs 16 bytes raw." Shows depth beyond "we use a TSDB."
- Cardinality is the interview differentiator. Most candidates describe ingest and query. Few mention cardinality explosion. Say: "The most dangerous production issue is unbounded tag cardinality — one bad deploy tagging with request_id creates millions of series and OOMs the cluster."
- Separate ingest path from query path. Draw them as independent pipelines that share TSDB storage. This shows you understand read/write isolation — writers don't block readers.
- Address "who monitors the monitoring?" Dead-man's switch: alert evaluator emits a heartbeat; a separate watchdog in a different failure domain checks it. This is the kind of operational nuance that impresses.
- Mention push vs pull trade-off proactively. "Prometheus pulls, Datadog pushes. Push is better for ephemeral containers. Pull is simpler for static fleets. Modern systems support both via OpenTelemetry Collector."
09
Failure Modes
Failure Modes
10
Evolution
Evolution
Nagios + RRDtool (early 2000s)
Static host list, manual config per check. RRDtool stores fixed-size round-robin databases — automatically downsamples old data by overwriting. Works for 50 servers with manual configuration. Falls apart at 500 hosts: every new host requires editing a config file and restarting the daemon.
Graphite + StatsD (2010s)
Push model via UDP. StatsD aggregates counters/timers/gauges client-side, ships pre-aggregated values to Graphite. Whisper DB stores one file per metric (fixed-size, round-robin). Better than Nagios, but single-node storage bottleneck. No native clustering — Carbon Relay adds sharding but no replication.
Prometheus + PromQL (2015+)
Pull-based scraping of /metrics HTTP endpoints. Local TSDB with Gorilla-inspired compression. PromQL provides powerful query language with rate(), histogram_quantile(), and label-based filtering. Single-node by design; Thanos and Cortex add horizontal scaling, long-term storage on S3, and global query view across clusters.
Managed SaaS — Datadog / New Relic (2018+)
Push model with hosted agents and auto-instrumentation. Managed TSDB at massive scale, ML-based anomaly detection, correlation across metrics/traces/logs. Zero operational burden for the customer. Cost scales with data volume — can get expensive at 10M+ series.
OpenTelemetry convergence (2023+)
Unified SDK for metrics + traces + logs — one instrumentation library instead of three vendor-specific ones. Vendor-neutral collection pipeline (OTel Collector) with processors and exporters. Any backend (Prometheus, Datadog, Grafana Cloud, Honeycomb) via config change. The industry standard going forward, ending vendor lock-in at the collection layer.
Watch and read
Try next
Free to read · better with Enzo
Whiteboard this with Enzo
Enzo runs it as a live system design round on the whiteboard and grades your trade-offs.