Design a metrics and monitoring system
The interviewer asks
“Design a metrics and monitoring system like Prometheus with long-term storage (Datadog, Grafana Mimir). Thousands of services emit metrics such as request rate, latency and CPU; engineers build dashboards, query recent and historical data, and get alerted when something goes wrong.”
What a strong answer looks like
A strong answer starts from the data shape: metrics are time series identified by a name plus labels, each receiving one small numeric sample every 10-60 seconds, so the system is extremely write-heavy and the main scaling risk is cardinality (the number of distinct label combinations), not bytes. Collection uses agents that scrape (pull) or receive pushes, then remote-write to a horizontally scaled ingestion tier: distributors validate and enforce per-tenant limits, and hash each series to a replicated set of ingesters that keep recent data in memory with a write-ahead log and compress it Gorilla-style (~1-2 bytes per sample). Every ~2 hours ingesters flush immutable blocks to object storage, where compactors merge and downsample them. Queries go through a query frontend that splits and caches, and queriers read recent data from ingesters and older data from blocks via an index. Alerting evaluates rules continuously and routes deduplicated, grouped notifications. Deep dives: the TSDB storage format, sharding and replication of ingestion, cardinality control, and reliable alerting (including monitoring the monitor).
Concepts to mention
1 Requirements
Functional
- Collect metrics (counters, gauges, histograms) from services, hosts, containers and Kubernetes, via pull (scrape) and push (OTLP / remote write).
- Store time series identified by metric name and labels, e.g. http_requests_total{service="checkout", status="500"}.
- Query with a language like PromQL: rates, aggregations by label, percentiles from histograms, over any time range.
- Dashboards that refresh every 10-30 seconds.
- Alert rules (e.g. error rate > 2% for 5 minutes) with routing, grouping, silencing and on-call escalation.
- Multi-tenancy: teams have their own limits and access; retention of 15 months with downsampling.
Non-functional
- Scale: ~200M active series, ~10M samples/s ingested.
- Ingestion must not drop data under load spikes (deploys create new series); per-tenant limits protect the system.
- Query p95: under 1 s for last-hour dashboards, under 10 s for 30-day queries.
- Alert evaluation every 15-60 s; alert from event to page within ~1-2 minutes.
- Availability 99.9% for writes and alerts, higher than the systems being monitored; the monitoring system must be independently monitored.
- Storage cost: object storage for long-term data; ~15 months retention.
Out of scope (say it out loud)
- Logs and traces (separate systems, linked by exemplars and labels).
- Anomaly detection with ML (mention only).
- Dashboard UI internals (assume Grafana).
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
GET/metrics (on each target) | Exposes current values in Prometheus text/OpenMetrics format for scraping. |
POST/api/v1/push (remote write, protobuf + snappy) | Agents send batches of samples {labels, [ts, value]} with X-Scope-OrgID tenant header. |
GET/api/v1/query_range?query=&start=&end=&step= | PromQL range query for dashboards. |
GET/api/v1/series?match[]= | Find series by label matchers (used by UIs and cardinality tools). |
PUT/api/v1/rules/{namespace} | Create or update alert and recording rules for a tenant. |
POST/api/v2/alerts (alertmanager) | Ruler sends firing alerts; alertmanager groups, dedupes, silences and routes. |
4 Data model
series identityIn-memory index in ingesters; inverted index in each blockseries_id = hash(sorted labels); labels map; postings lists label=value → [series_ids]An inverted index answers matchers like {service="checkout", status=~"5.."} by intersecting postings lists.
head block (recent data)Ingester memory + WAL on local SSDper series: chunks of ~120 samples compressed with delta-of-delta timestamps and XOR floatsLast ~2 hours. WAL lets an ingester recover after a crash.
persistent blocksS3 / GCS (object storage)block_id (ULID)meta.json (time range, tenant)chunks filesindex fileImmutable 2-hour blocks per tenant, compacted to 24h blocks; replicas deduplicated at compaction.
downsampled blocksS35m and 1h resolution with minmaxsumcount per intervalKeeps long-range queries fast and cheap; percentiles approximated from histogram buckets.
ring / membershipetcd or Consul (or gossip memberlist)ingester_idtokens on hash ringstate (ACTIVE|LEAVING|JOINING)zoneDistributors route series by hash to ring tokens; replication factor 3 across zones.
query results cacheMemcached / Rediskey = (tenant, query, step, aligned time range split by day) → resultQuery frontend caches results per day split; only the latest partial interval is recomputed.
rules and alert stateObject storage / config DB + alertmanager state (replicated)rule groups per tenant; active alertssilencesnotification logAlertmanager replicas gossip notification state to avoid duplicate pages.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Ingest a sample
- 1An agent in each Kubernetes cluster discovers pods, scrapes /metrics every 15 s, adds labels (cluster, namespace, pod) and batches samples.
- 2It remote-writes a compressed batch to the gateway with the tenant ID; failed writes are retried from a local WAL buffer.
- 3A distributor validates labels, applies per-tenant limits (samples/s, max series, label length), hashes each series' labels and finds 3 ingesters on the ring in different zones.
- 4It sends the samples to the 3 ingesters in parallel and returns success when 2 of 3 acknowledge (quorum).
- 5Each ingester appends to its WAL, adds the sample to the series' open chunk in memory, and every 2 hours cuts a block and uploads it to object storage.
Dashboard query for the last 7 days
- 1Grafana sends a query_range for error rate by service over 7 days with a 5-minute step.
- 2The query frontend splits it into 7 daily sub-queries, returns cached results for the 6 complete days, and queues only today's part.
- 3A querier fetches the last ~2-12 hours from ingesters and older data via store gateways, which use the block index (cached) to read only matching chunks from S3.
- 4The querier merges and deduplicates replicas, evaluates PromQL, and the frontend stitches and caches results.
An alert fires
- 1The ruler, sharded by rule group, evaluates 'rate of 5xx / rate of requests > 2% for 5m' every 30 s.
- 2When the condition holds for 5 minutes, it sends a firing alert with labels (service, severity, team) to alertmanager; it re-sends periodically while firing.
- 3Alertmanager groups alerts by service and cluster, waits ~30 s to batch related alerts, checks silences and inhibitions, and routes to the owning team's on-call via PagerDuty and Slack.
- 4Alertmanager replicas share a notification log via gossip, so each group pages once.
7 Deep dives
Time-series storage: why it is cheap per sample
Monitoring data has a special shape: each series gets samples at nearly regular intervals, values change slowly, and data is written once and read in time ranges. A TSDB exploits this. Samples are grouped per series into chunks of ~120 samples. Timestamps are stored as delta-of-delta: with a 15 s interval, most deltas-of-deltas are zero and take 1 bit. Values use Gorilla XOR encoding: XOR with the previous value, storing only the meaningful bits; slowly changing values compress to a few bits. Facebook's Gorilla paper reported ~1.37 bytes per sample, versus 16 bytes raw. Recent data lives in memory in a head block, protected by a write-ahead log; every 2 hours it becomes an immutable block on disk or object storage with its own inverted index (label=value → series IDs) and chunk files. Immutable blocks make compaction (merge small blocks into larger ones, remove duplicated replicas, apply deletions) and downsampling simple background jobs. Queries locate series via the index, then read and decode only the chunks in the time range. Updates in place do not exist, which is exactly why it is fast.
Sharding and replicating ingestion
One Prometheus server handles a few million active series; beyond that you need a horizontally scaled design. Distributors are stateless: they receive writes, validate, and route each series by a hash of its tenant and labels onto a consistent hash ring of ingesters, with replication factor 3 placed across availability zones. A write succeeds when a quorum (2 of 3) acknowledges, so one slow or failed ingester does not block ingestion. Because every sample of a series goes to the same 3 ingesters, they can compress efficiently. Ingesters are stateful: when one leaves, it must flush or hand over its in-memory series; when one crashes, it replays its WAL on restart, and the other two replicas covered the gap. Replicas create three copies of each block in object storage; the compactor deduplicates them. Hot tenants are handled with shuffle sharding: each tenant uses a subset of ingesters, so one tenant's explosion affects only its subset. Scale queries independently: queriers are stateless, store gateways shard blocks among themselves, and the query frontend adds splitting, caching and per-tenant fairness queues.
Cardinality: the failure mode that kills metrics systems
Cost and memory scale with the number of active series, and each unique combination of label values is a new series. A developer who adds user_id, order_id or a full URL path as a label can create millions of series in minutes, blowing up ingester memory and slowing every query. Defend in layers. At the source: client libraries and code review rules (labels must be bounded sets; use route templates like /orders/{id}, not raw paths). At ingestion: per-tenant limits on active series, series per metric and label value length, enforced by distributors, which reject new series beyond the limit while keeping existing ones flowing. Visibility: a cardinality explorer showing top metrics and labels by series count, and alerts when a tenant's series grows 2x in an hour. Also manage churn: Kubernetes pods change names on every deploy, so pod labels create new series each deploy; short head-block retention and dropping pod labels for aggregated metrics help. Per-user or per-request data belongs in logs, traces or an OLAP store, not metrics.
Reliable alerting and monitoring the monitor
Alerting is the part people depend on at 3 am, so it needs higher reliability than dashboards. The ruler evaluates rules on a schedule against the same query path, sharded by rule group with leases so each group is evaluated by exactly one ruler at a time. Use 'for: 5m' durations to avoid paging on blips, and prefer alerting on symptoms tied to SLOs (error budget burn rate over 1 hour and 6 hours) over causes like CPU. Alertmanager deduplicates (the ruler re-sends firing alerts), groups related alerts into one notification, applies silences during maintenance and inhibitions (do not page for 500 services when the whole cluster is down), and routes by team labels. Run it as a replicated cluster that shares notification state via gossip, so failover does not double-page or miss. Finally, the monitoring system cannot alert about its own death. Use a dead man's switch: an always-firing 'Watchdog' alert sent to an external service that pages if the heartbeat stops, plus a small independent Prometheus that monitors the monitoring stack itself.
8 Trade-offs
Pull vs push collection
Pull (scrape) gives the server control of rate, makes 'target is down' detectable (scrape fails), and is simple to debug with curl. Push suits short-lived jobs, serverless functions and clients behind NAT. Use agents that scrape locally and push (remote write) to the central system, which combines both, and OTLP push for apps instrumented with OpenTelemetry.
Build vs buy (Datadog, Grafana Cloud)
SaaS removes operational load, but costs scale with series and hosts, and large Indian companies often find bills in crores per year and data-residency questions. Open-source stacks (Prometheus + Mimir/Thanos/VictoriaMetrics) cost engineers' time but are cheaper at scale. Mention FinOps: cost per team based on series counts.
Long retention at full resolution vs downsampling
Keeping 15-second data for 15 months makes every long query scan billions of samples. Downsampling to 5-minute and 1-hour aggregates (min, max, sum, count) keeps long queries fast and storage small, losing fine detail that is rarely needed after a month.
9 Bottlenecks
Deploy-time series churn
A large deploy replaces thousands of pods, creating new series for every metric and making ingesters hold old and new series at once. Plan memory for ~2x active series during deploys, roll deploys in waves, and avoid high-churn labels on high-volume metrics.
Expensive queries
A query over all services for 90 days at 15-second resolution can scan terabytes. The query frontend enforces limits (max samples, max series, time range), splits by day and caches, and auto-uses downsampled data for long ranges. Recording rules precompute expensive aggregates used by dashboards and alerts.
10 Failure handling
An ingester crashes
Writes continue with 2 of 3 replicas (quorum). The ingester restarts, replays its WAL and rejoins. If it is lost permanently, its data since the last block upload exists on the other replicas. Queries merge replicas, so users see no gap.
Object storage slow or unavailable
Ingesters keep data in memory and on local disk longer and retry uploads; recent queries still work from ingesters. Long-range queries fail or degrade. Alerting uses recent data only, so it keeps working, which is a key design choice: alert rules should not depend on historical queries.
Agent cannot reach the central system
Agents buffer samples in a local WAL for a few hours and replay when connectivity returns. Local alerting in the agent (or a small per-cluster Prometheus) covers critical alerts during network partitions.
11 What impresses
- Identifies cardinality as the main scaling risk and designs limits and tooling for it.
- Explains why compression reaches ~1-2 bytes per sample.
- Uses quorum writes to replicated ingesters and deduplicates replicas at compaction.
- Keeps alerting dependent only on recent data so it survives object storage problems.
- Mentions a dead man's switch and an independent meta-monitoring stack.
- Uses recording rules and query splitting to keep dashboards fast.
12 Mistakes
- Storing metrics in a relational database with one row per sample.
- Allowing unbounded labels such as user_id or request path.
- Sending all metrics through Kafka without explaining why (it adds cost and latency; acceptable only as a buffer).
- Alerting on every CPU spike with no for-duration or grouping, causing alert fatigue.
- Forgetting that the monitoring system itself needs monitoring.
- No retention or downsampling plan.
13 Follow-ups
Lessons this design uses
Practice questions
- You are launching a new order-tracking API next week. What would you put on its first dashboard and why?
- Your dashboard shows average latency of 120 ms, but customers complain the app is slow. Each of your 40 pods also reports its own p99. How do you compute the true service-wide p99?
- Your on-call engineers get paged 30 times a week, mostly for 'CPU above 80%' and 'disk above 70%', and most pages need no action. How would you fix the alerting?
- You alert when the error rate is above 0.1% for 5 minutes, matching your 99.9% SLO. It is both noisy and slow to catch slow leaks. How would you design SLO-based alerts instead?
- A developer added user_id and order_id as labels on a Prometheus request counter. Two days later Prometheus is running out of memory. Explain why and what you would do.
- During a major outage last month, your Prometheus and Grafana, running in the same cluster as production, went down too, and no alerts fired. How would you design monitoring so it survives the outages it is supposed to detect?
- Estimate the storage for a metrics monitoring system that collects 10 million active time series every 10 seconds, kept for 30 days at full resolution.
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.