Design the Metrics System
- Scope a metrics pipeline that ingests a firehose of points and serves tag-filtered, time-range queries fast
- Walk the agent-to-query path end to end, naming the buffer, the store, and the tag index that make it work
- Reason through the parts that separate a senior answer: rollups, percentile sketches, the tag index, and the write firehose
Understanding the problem
"Design a monitoring service like Datadog or SignalFx, assume an agent on every host" is the Datadog system design round in one sentence, and it is the most-asked design prompt in the loop. The shape is concrete. Millions of hosts each run a small agent that emits metrics every ten to fifteen seconds: a name like request.latency, a set of tags like service:web, region:us-east, host:i-0abc, a numeric value, and a timestamp. Our job is to ingest that firehose, store it cheaply, then answer queries like "p95 latency for service=web over the last hour, broken down by region" in under a second. Everything downstream is shaped by that volume.
Get the vocabulary down before anything else; every later choice hangs on it. A single metric point is the four-tuple of name, tags, value, and timestamp. A time series is the identity formed by a name plus a specific tag set: request.latency{service:web, region:us-east, host:i-0abc} is one series, and the same metric reported from a different host is a different series. Storage is organized around series, not individual points, and a query selects a set of series by their tags before aggregating over a time window. Fix that early. The whole design rotates on the series and its tag set, read over a window.
Functional requirements
Hold the scope to three verbs: ingest, store, query. The agent reports points, we durably accept them, and a user runs a time-range query that filters by tags and aggregates. The one aggregation we must get right is the percentile, because the round leans on it and because it is the part that breaks naive designs. Everything else is below the line.
In scope
- Ingest metric points from an agent on every host (name, tags, value, timestamp)
- Store time series cheaply at six-months retention
- Query a tag-filtered set of series over a time range (
service=web AND region=us-east) - Aggregate over the window, including percentiles (p50, p95, p99) and group-by tags
Below the line (call it out, then skip)
- Alerting and the monitor evaluation loop (you build that engine as a deep dive in the budgeting-app design, next). A textbook "design Datadog" sometimes folds alerting in, so name it as a deliberate scope cut rather than an omission
- Logs and traces (separate pipelines with their own storage)
- The dashboard rendering and the UI (assume the web client exists)
- The agent's internal collection logic (we treat it as a client that POSTs)
- Multi-tenant billing, access control, and quotas
Non-functional requirements
Put a number on each one, because these decide the architecture. Write volume is the dominant constraint. We accept on the order of a billion points per second at peak, and we must never drop the firehose when storage hiccups, so a buffer has to decouple ingest from the write path. Reads are far rarer than writes but latency-sensitive: a dashboard query should return in well under a second even when it touches millions of series. Freshness can be relaxed to a few seconds. A point reported now showing up on a graph a handful of seconds later is fine, which buys us an asynchronous pipeline. And percentiles are allowed to be approximate, accurate to a percent or two, which is the single relaxation that makes the design affordable.
Cost is a first-class requirement here, because storing every raw point at this volume is the fastest way to bankrupt the design. We will trade exactness for money in two places, downsampling old data and approximating percentiles, and we flag both now so the deep dives can cash them in. Availability matters more than strong consistency: a metrics backend that is briefly behind is acceptable, but the worst possible moment to be down is the middle of an outage. So we accept being eventually-correct rather than blocking writes to stay consistent. Stay up first. Be exactly right second.
- ~100T Points per day. ~1.2B points/sec at peak.
- < 1 s Query latency. Even across millions of series.
- ~Few sec Freshness. Async pipeline is fine.
- Approx OK Percentiles. Accurate to a percent or two.
The set-up
Core entities
Name the data the system passes around first, then draw. Four carry the design:
MetricPoint
One emitted reading: a metric name, a tag set (an unordered bag of key:value pairs), a numeric value, and a timestamp. This is the firehose unit, billions a second. We never want to store it one-row-each forever.
TimeSeries
The identity formed by a metric name plus one exact tag set. All points sharing that identity form a single series. Storage and rollups are keyed by a series_id, a stable hash of name plus the sorted tag set, so the same series always lands in the same place.
Rollup
A pre-aggregated summary of one series over one time window: a per-minute or per-hour bucket holding sum, count, min, max, and a percentile sketch. Rollups are what we keep at scale; raw points are short-lived.
TagIndex entry
The inverted-index mapping from a single tag (region:us-east) to the set of series_ids carrying it. Queries intersect these posting lists to find which series a filter selects, without scanning every series.
The store under the rollups is a time-series store. We will name Cassandra as the concrete pick. The access pattern is a perfect fit, and it is the database the original Datadog prompt explicitly asks you to reach for. Every write is an append keyed by series and time. Every read is a contiguous range scan over one series within a window. There is essentially no update-in-place. We partition by series_id so one series lives together and a range scan is sequential, and we order within a partition by timestamp so a time-range read is one continuous sweep. Cassandra's wide-row, write-optimized, horizontally-sharded model is built for exactly this, and it sustains the write rate by spreading partitions across many nodes. The tag index is a separate store, an inverted index we discuss in its own deep dive, because mixing the "which series" lookup into the time-ordered store would wreck both access patterns.
API
Two endpoints carry everything. The agent batches many points into one POST every interval rather than one request per point, because a billion tiny HTTP calls a second would melt any gateway. The query endpoint takes a metric and a tag filter over a time range, plus the aggregation to apply, and returns a series of timestamped values, already rolled up to a resolution that suits the range.
The POST returns 202 Accepted on purpose, where a 200 OK would overpromise. It tells the agent we durably buffered the batch, and says nothing about whether the batch is queryable yet. That single status code is the contract that lets us put a buffer between ingest and storage, which is what survives the firehose. The query parameters preview the three deep dives that matter: agg=p95 forces the percentile question, filter forces the tag index, and from/to forces the rollup-and-retention story. We build each below.
High-level design
Go requirement by requirement, standing up the simplest working version before any hardening. The colors below sort components by role. Blue is the Agents on every host. Violet is the services: the Ingest gateway, the Write consumers, and the Query service. Amber is the Kafka buffer that absorbs the firehose. Green is the Time-series store and the Tag index. The walkthrough tags each component the same way.
1) Ingest the firehose. The Agents batch points and POST them every interval to the Ingest gateway, a stateless fleet that authenticates and lightly validates and does nothing slow. It writes each point straight to the Kafka buffer keyed by series id and returns 202. The gateway never touches a database, so it scales out flatly and stays cheap. And because the agent batches, the gateway sees a manageable rate of fat requests rather than a billion tiny ones. The naive version we are deliberately not building yet would have the gateway write straight to the store synchronously, which collapses the instant the store hiccups. Deep dive four explains exactly why the buffer is not optional.
2) Buffer the write firehose in Kafka. A queue earns its box here because the firehose is bursty and the store cannot be allowed to apply backpressure all the way to the agents: bursts have to wait somewhere that is not the request path. We partition the Kafka buffer by series id so every point of one series lands on the same partition in timestamp order. That ordering lets a single consumer aggregate a window with no cross-partition coordination. Kafka holds points durably for a retention window, so a slow or restarting store loses nothing. What we punt on here is duplicates: at-least-once delivery means a point can be replayed, and a percentile or a count that double-counts is wrong. Deep dive four settles the dedupe.
3) Roll up and store. The Write consumers read each partition and do the aggregation the design is built around. Rather than store every raw point, a consumer groups a series' points into a per-minute window and writes one Time-series store row holding the window's sum, count, min, max, and a percentile sketch. The first time it sees a series it also registers that series in the Tag index. The store is partitioned by series id and ordered by time, so this is an append. We are punting two big questions. Deep dive one is how rollups and retention keep 100T points a day from exploding the bill. Deep dive two is how a sketch lets us serve p95 without keeping raw values.
4) Serve the query. A dashboard hits the Query service with a metric and a tag filter over a time range, plus the aggregation to apply. It first asks the Tag index which series ids the filter selects, intersecting the posting lists for service:web and region:us-east. Then it range-scans those series' rollups from the Time-series store at a resolution that fits the range. An hour query reads per-minute rollups, a month query reads per-hour ones. It merges the per-window aggregates across series and returns the result. It reads rollups, never raw points. Reads are rare. Writes are the firehose. The punt is the index itself: resolving a filter over millions of series fast, and surviving high-cardinality tags, is deep dive three.
Technology choices
- Ingest gateway is a stateless service fleet (any language, Datadog’s real agent and intake are Go) behind a load balancer. It holds no state, so when one falls over the load balancer routes around it and a new one spins up cold. The only failure that matters is the gateway outrunning Kafka, which it can’t for long because writing to a partition is a fast append; if a Kafka broker is unavailable the gateway writes to another in-sync replica.
- Kafka buffer: Kafka is the event log that decouples ingest from storage. Partitioned by series id so one series is one ordered stream; replicated across brokers so a broker failure promotes an in-sync replica and the consumer resumes from its committed offset with no lost points. Consumer lag, how far behind the latest offset the consumers are, is the freshness dial: rising lag means the store is struggling, and we can scale consumers or shed load before dropping anything.
- Write consumers are a consumer group, one set of partitions per consumer, that does the rollup-and-sketch work. If a consumer dies, its partitions are reassigned and the replacement replays from the last committed offset, which is why the rollup write has to be idempotent (keyed by series plus window) so a replay overwrites rather than double-counts. This is the work-queue-versus-event-log distinction in action: Kafka is an event log we can rewind and replay, not a queue that deletes on read.
- Time-series store: Cassandra (or Datadog’s columnar Husky on object storage). Wide-row and write-optimized: partition key is the series id, clustering key is the timestamp, so a write is an append and a time-range read is one sequential scan. Horizontally sharded across nodes to sustain the write rate; replication factor three for durability and to survive a node loss. The probe it invites, "what if a hot series overwhelms one partition," is answered by sharding a hot series across sub-partitions by time bucket.
- Tag index is an inverted index from tag to series ids, the same idea as a search index, served from a store tuned for set intersection (Elasticsearch-style postings, or a custom index). Its failure mode is not a crash but cardinality: a tag with millions of distinct values bloats the postings and slows intersection, which deep dive three handles head-on. The brand is less important than the category; what matters is that it answers "which series match this filter" without a full scan.
Potential deep dives
At Datadog the deep dives decide the level, and most of the signal sits here. The interviewer locks onto one part of the pipeline and pushes until the cost or the correctness gives way. The four below recur the most. Each is scored against the same tiers, and each opens on the number that forces the design.
1) How do we store 100T points a day without the cost exploding?
Start with the arithmetic, because it is the whole reason this design is hard. Take two million hosts, each emitting on the order of 8,640 distinct series, reporting every fifteen seconds. A day has 86,400 seconds, so a series reports 86,400 / 15 = 5,760 times a day. Multiply: 2,000,000 hosts × 8,640 series × 5,760 points ≈ 1.0 × 10^14. That is about 100 trillion points a day. Average that over the day and it is 100T / 86,400s ≈ 1.2 billion points a second. If each raw point costs roughly 12 bytes (a timestamp and a value), 100T points is about 1.2 petabytes a day uncompressed, and storing six months of that as raw rows is hundreds of petabytes. Nobody pays that bill. The data almost nobody queries at one-second resolution months later is not worth keeping that way.
Approach
Write each incoming point as a (series_id, timestamp, value) row in the store and never delete it. Queries scan the raw rows for the requested window and aggregate on the fly.
Challenges
The cost grows without bound, about 1.2 PB a day, and a six-month query has to scan months of one-second-resolution rows to draw a single line on a graph. Nobody looks at last quarter at per-second detail, so we are paying to store and scan resolution no one wants. Worse, every query redoes aggregation the write side could have done once. Name it, then knock it down and move on.
What forces the upgrade is the observation that resolution and retention are inversely valuable: recent data is read at fine resolution, old data is read coarsely or not at all. So we do the aggregation once, on the write path, and we age data down through resolution tiers instead of keeping it raw.
Approach
The Write consumers aggregate each series into fixed windows as they ingest. A per-minute rollup collapses 60/15 = 4 raw points into one row holding sum, count, min, max, and a sketch, a 4× reduction immediately. Then retention tiers age the data down: keep raw points for only a few hours (long enough to debug a live incident), keep per-minute rollups for, say, fifteen days, and keep per-hour rollups for the full six to fifteen months. A per-hour rollup is 60 per-minute rows collapsed into one, another 60×. Old high-resolution data simply expires; a background job rolls per-minute into per-hour and drops the finer tier. Queries read the coarsest tier that still answers the question: a one-hour query reads per-minute rollups, a one-month query reads per-hour rollups, so a long-range graph scans thousands of rows, not billions.
Challenges
Rollups are lossy: once a per-minute bucket is written you cannot reconstruct the four raw values inside it, so the retention windows have to match how people actually debug (a few hours of raw is the number incident responders need). Sum, count, min, and max all roll up cleanly by adding or taking extremes across windows, but a percentile does not, which is exactly why the sketch rides along in every rollup and why deep dive two exists. And a rollup write must be idempotent, keyed by series plus window, so a Kafka replay overwrites the bucket rather than double-adding into it.
The scaling writes lesson covers absorbing and pre-aggregating a high write volume, and Cassandra and DynamoDB covers the wide-row, time-partitioned store the rollups live in.
2) How do we serve p95 without storing every raw value?
This is the percentile-over-time-series question, asked almost word for word, and it is the one naive designs get wrong twice. A p95 for one series over one minute means: of all the latency values in that minute, the value below which 95% of them fall. Two facts make it hard. First, at this volume a single busy series can record millions of values a minute, so keeping every value to sort later is exactly the cost we just refused to pay. Second, and less obvious, you cannot average percentiles. If host A's p95 is 100ms and host B's p95 is 200ms, the combined fleet's p95 is not 150ms. A percentile is a position in a sorted distribution, and positions do not add. So we need a structure that summarizes a distribution cheaply and, crucially, can be merged across hosts and windows.
Approach
Either keep all the raw latency values so any percentile can be computed exactly at query time, or have each host compute its own p95 and average those together across the fleet.
Challenges
Both are wrong, in opposite ways. Storing every raw value is the 100T-points-a-day cost we already rejected, just relocated, and it makes the rollup pointless. Averaging the per-host percentiles is statistically invalid: a percentile is a rank in a distribution, not a quantity you can add, so the average of two p95s is a number that means nothing. Say "average the p95s" in the room and the interviewer knows you haven't thought about what a percentile is. Be ready to say exactly why it breaks.
What we need is a small, fixed-size summary of a distribution that gives an approximate percentile and, the key property, can be merged: combine two hosts' summaries and read the fleet's percentile off the result. That structure is a histogram-style sketch, and it ties straight back to the coding round.
Approach
Each Write consumers keeps, per series per window, a sketch: a compact set of buckets, each holding a count of values that fell in its range, where the bucket boundaries are log-scaled so resolution stays proportional from microseconds to seconds. To read a percentile you scan buckets low to high, summing counts, until the running total crosses the rank you want, then report that bucket’s bound. The answer is approximate, accurate to a bucket’s width, which is the percent-or-two error the requirements allowed. The magic property is mergeability: because two sketches over the same bucket layout combine by adding their counts bucket-by-bucket, the Query service reads each series’ sketch from a window, adds them, and reads p95 off the merged result. The same merge rolls a minute of sketches into an hour. Datadog open-sourced exactly this structure, DDSketch, with a guaranteed relative-error bound; t-digest is the other well-known mergeable sketch.
Challenges
The error is bounded but real, so you state it: a percentile read from a sketch is accurate to the bucket width, which log-scaling keeps to a fixed relative percentage rather than letting the slow tail collapse into one bucket. Merging requires a shared bucket layout across all hosts, so the sketch parameters are fixed fleet-wide, not chosen per host. And the sketch costs more bytes than a single sum or count, a few hundred bytes per window per series, which is the price of being able to answer percentiles at all; it is still vastly cheaper than the raw values.
This is the same structure the latency histograms coding round builds by hand: bucket counts, log-scale buckets for the tail, a percentile read as a running-sum scan until you cross a rank, and merging two histograms by adding their counts. The coding round implements one host's sketch; this design distributes it across the fleet. Saying "this is the histogram from the coding round, made mergeable and rolled up over time" connects the two rounds in a way that lands.
3) How do we serve a tag-filtered query fast over millions of series?
A query like service:web AND region:us-east has to find which series match before it can read any data, and the count is the problem. With two million hosts each carrying thousands of series, there are on the order of hundreds of millions to billions of active series. Checking each one's tags against the filter is a full scan of billions of records for every dashboard refresh, which blows the sub-second budget instantly. We need to go from filter to matching series ids without looking at the ones that do not match. A full scan is off the table.
Approach
Keep series with their tag sets and, for each query, iterate over all series, keeping the ones whose tags satisfy the filter.
Challenges
It is a scan over hundreds of millions of series per query, which cannot meet a sub-second latency target no matter how the store is tuned, and it gets slower as the fleet grows. Every dashboard tile would pay this cost on every refresh. The fix is the standard one for "find the items matching a set of attributes," which is an inverted index, the same structure a search engine uses to go from a word to the documents containing it.
Approach
Build the Tag index as an inverted index: for each tag value, keep a sorted posting list of the series ids carrying it. service:web maps to one list, region:us-east to another. A filter is answered by intersecting the posting lists, an AND is a set intersection and an OR is a union, which over sorted lists is a fast merge that touches only the matching series and skips the rest. The result is the exact set of series ids to range-scan in the time-series store, so the expensive data read happens only for series that actually match. The consumers register a series in the index the first time they see it, which is rare relative to writes because most points belong to series that already exist, so the index is written far less often than the firehose flows.
Challenges
The trap is cardinality, and it is the real Datadog pain. The index size and intersection cost scale with the number of distinct tag values, so a low-cardinality tag like region (a handful of values) is cheap, but a high-cardinality tag like user_id or request_id (millions of distinct values) explodes the index: millions of one-entry posting lists, a series count that balloons because every distinct tag combination is a new series, and intersections that crawl. This is why Datadog meters custom metrics by cardinality and why putting an unbounded id in a tag is the classic way to blow up a metrics bill. The mitigations are real: cap or reject runaway-cardinality tags at ingest, separate genuinely high-cardinality dimensions out of the metrics path, and treat cardinality as a budgeted resource rather than a free dimension.
Elasticsearch covers inverted indexes and posting-list intersection, the same machinery a search engine uses to map a term to its documents.
user_id multiplies the series count and the index size without bound? And would you defend the system by metering or rejecting it at ingest? Bring up cardinality yourself, and you've named the one place this exact system breaks in production.4) How do we handle the write firehose without dropping points?
Back to the rate: roughly 1.2 billion points a second on average, and far higher in bursts when a fleet redeploys or an incident makes everything noisy at once. The store cannot absorb that directly, and the one thing a metrics backend must never do is silently drop points during the exact incident an engineer is staring at the dashboard for. So the question is how ingest stays up and lossless when the store is slow or briefly down. Losing points is not allowed.
Approach
The ingest gateway takes each batch and writes it directly into the time-series store, returning success once the store acknowledges.
Challenges
The store’s availability becomes the ingest path’s availability. A node restart, a compaction pause, or a hot partition makes the store slow, and that backpressure propagates all the way to the agents, which then either block or drop points on the floor, precisely when load is highest. There is no place for a burst to wait, so a burst becomes data loss. And the bursts are not small: a synchronized fleet redeploy or a noisy incident can spike the rate several times over the 1.2-billion-a-second average for minutes at a stretch, which is exactly when the data matters most and exactly when a synchronous store is most likely to stall. This is the design the buffer exists to prevent.
Approach
Put the Kafka buffer between the Ingest gateway and the store, partitioned by series id. The gateway’s job ends at a durable append to Kafka, which is fast and decoupled from the store’s health, so it returns 202 immediately. The Write consumers drain Kafka at their own pace. When the store slows, points pile up safely in Kafka instead of being dropped. Consumer lag rises, which is the freshness dial we alert on, and we scale consumers or shed low-priority metrics rather than lose data. Partitioning by series id keeps each series’ points ordered on one partition, so a consumer aggregates a window without coordinating across partitions. Delivery is at-least-once, so a rebalance or restart can replay points; we make the rollup write idempotent by keying each bucket on series id plus window, so a replayed point lands in the same bucket and a re-applied rollup overwrites rather than double-counting.
Challenges
Kafka’s own durability has to be real: replication factor three and acknowledged writes, or the buffer that was supposed to prevent loss becomes the place loss happens. Idempotency is non-negotiable because at-least-once will replay, and a double-counted point corrupts a sum or inflates a sketch, so the series-plus-window key has to be the dedupe boundary. And the buffer trades latency for safety: a point is durable in milliseconds but queryable only after a consumer rolls its window, which is the few-seconds freshness the requirements already accepted. Under sustained overload the honest move is graceful degradation, shedding the least important metrics, never dropping silently.
One follow-up the interviewer reliably reaches for: partitioning by series id is right for ordering, but it can create a hot partition. A handful of extremely busy series, say a metric on a load-balancer tier that every request touches, can pile far more volume onto their partitions than onto others. One overloaded partition then slows the consumers that own it. The fix is to spread a hot series across sub-partitions, hashing on series id plus a small time bucket so its load fans out, then merging the sub-partition rollups back together at write time. The same hot-key reasoning shows up on the read side, where one viral series is read far more than the rest. A small read cache in front of the Query service absorbs that.
The scaling writes lesson covers buffering a high write rate, and Kafka covers partitioning and consumer groups, plus the offset replay and at-least-once delivery that force idempotent consumers.
What is expected at each level
That is a lot of surface for one pipeline. How far you take it scales with the level you are targeting, and the round leans on depth, because the reject that recurs is a strong coder who never gets concrete about design.
Mid-level
Breadth first. Get the pipeline right end to end. Agents batch and POST, a gateway writes to a buffer, consumers roll points up into per-minute aggregates and store them, and a query reads those rollups over a time range. The one cost idea to land is that you store rollups, not every raw point, and you should be able to say why a per-minute aggregate is cheaper than the raw rows it replaces. The interviewer will drive the percentile and tag-index depth. The bar: a clean, working pipeline with rollups, where you can explain why keeping raw points forever is too expensive. It is fine if the sketch and the cardinality cases need prompting.
Senior
Breadth first, then real depth you reach on your own. Two problems should surface without prompting. First, percentiles: you cannot store every value, and you cannot average per-host p95s, so you keep a small mergeable sketch per window instead. Second, the tag filter: an inverted index from tag to series ids, intersected as posting lists, is what turns a filter into a set of series without scanning them all. Defend Kafka as a deliberate buffer too, not decoration. Consumer lag is the freshness dial you watch, and idempotency is how you survive a replay. The bar: you find the percentile and tag-filter bottlenecks without being led to them, take each to a Great tier, and explain why you picked the buffer and the time-series store.
Staff+
By the staff bar the scope widens, and the cardinality fight is yours to drive. Treat cardinality as a budgeted resource: you cap or reject runaway tags right at ingest. Own the retention tiers too. Raw data ages into per-minute rollups and then per-hour ones, and that is how you keep the cost in line. Drive the mergeability of the sketch as the one property that makes a fleet-wide percentile possible at all. Then widen out. Add multi-region ingest, where each region runs its own buffer and store, and treat cost as a lever you pull at every layer. Under overload, you shed low-priority metrics on purpose rather than letting points drop silently, and you call that a design decision, not an accident. The bar: you lead and the interviewer only nudges, never rescues, and you reason about cost and cardinality end to end. The percentile sketch and the retention tiers stay at the center of your design, which is where a strong answer keeps them.
Key Takeaways
- ✓The volume is the design. At ~100T points a day and ~1.2B/sec, you do not store raw points, you roll them up at write time and age them down through retention tiers (raw for hours, per-minute for days, per-hour for months).
- ✓You cannot average percentiles and cannot afford to store every value, so each series carries a mergeable sketch (DDSketch / t-digest) per window. Percentiles are approximate, accurate to a bucket width, and combine across hosts by adding bucket counts, the same histogram as the latency-histograms coding round.
- ✓A tag-filtered query needs an inverted index from tag to series ids, intersected as posting lists. Its failure mode is cardinality: a high-cardinality tag like user_id explodes the index and the series count, which is the real Datadog cost trap.
- ✓Kafka is the shock absorber that keeps the firehose lossless when the store hiccups: partitioned by series id, with idempotent consumers (keyed by series plus window) to survive at-least-once replays, and consumer lag as the freshness dial.
Datadog interview guideKnow what Datadog asks before you walk in.
11 coding · 4 system design · 30 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.