← System design

Storage & Data

Design Distributed Logging Framework

Logs are the data nobody reads until the night everyone reads them at once. The platform spends 99% of its life writing a million lines a second that no human will ever look at, and then has to answer an engineer's panicked search across a hundred services in a few seconds. Designing it well is mostly about refusing to pay search-engine prices for data that is almost never searched.
Core: label index + compressed chunks, scan in parallel360K lines a second, 1.1M at peakWrite-heavy, read-rarelySearchable within 10 s of emit

01

What we are building

01

What we are building

On one server, debugging is ssh and grep. The log file is local, it sits next to the process that wrote it, and the timeline of one request is a few consecutive lines. Move to 1,500 services running as 60,000 short-lived containers and all three of those properties vanish. Containers are rescheduled and their disks disappear with them. One checkout request touches the gateway, the cart, pricing, inventory, payments and an email sender, each on a different node. By the time someone notices a problem, the pod that logged the clue may have been gone for an hour.

We need a central system that collects every log line from every container, stores it durably and cheaply, and lets engineers ask questions such as "show me every ERROR from the payments service in the last 30 minutes that mentions this order ID". It also needs a live view for watching a deploy, and alert rules that fire when error patterns appear. Think of the internal platform behind tools like Grafana Loki, Elastic, Splunk or Datadog Logs.

The write side

Ingest never stops and never gets a quiet hour. It must accept bursts several times the average when a bad deploy starts logging stack traces in a loop, without slowing down the services that emit them.

The read side

Queries are rare, bursty and urgent. Most look at the last hour of a few services; a few scan weeks of everything for an audit. Both deserve an answer.

The bill

At this volume, storage and indexing choices decide whether the platform costs tens of thousands or millions of dollars a year. Cost per gigabyte ingested is a first-class requirement.

Scope it out loud

Logs only. Metrics and traces are separate systems with different shapes, though we will carry a trace_id field so a log line can link to its trace. Say this early so the interview does not become "design all of observability".

02

Scope and targets

02

Scope and targets

Functional
  • A node agent collects stdout, stderr and file logs from every container and attaches labels: cluster, namespace, service, pod, level.
  • Structured JSON lines keep their fields; plain text lines are stored as a message with labels.
  • Search by time range plus label filters, then by substring, regex or field match, for example {service="payments", level="error"} |= "ORD-77120".
  • Live tail for a label selector, with lines appearing a second or two after they are written.
  • Alert rules that count matching lines over a window and notify a pager or chat channel.
  • Retention set per namespace, from 7 days for debug noise to a year for audit logs, plus delete-by-query for privacy requests.
Non-Functional
  • Ingest 360,000 lines a second sustained and absorb 3x bursts without dropping data.
  • A line is searchable within 10 seconds of being written, and visible in live tail within 2.
  • A one-hour search over a few services returns in under 3 seconds; a 30-day scan in under a minute.
  • The agent never blocks the application; if the pipeline is down, the agent buffers to local disk.
  • Lose no acknowledged data, and stay within a budget of about $0.10 per GB ingested all-in.
Questions that change the design

How often do people search, and how far back? If 95% of queries touch the last 24 hours, a full-text index over a year of data is wasted money. Are there compliance logs that must be immutable? Do teams log personal data that must be redacted or deletable on request?

03

Estimating the firehose

03

Estimating the firehose

Start from the fleet: 1,500 services averaging 40 pods each, every pod emitting about 6 lines per second. Average line size including labels is 380 bytes. Queries are counted per engineer: about 4,000 engineers, a handful of searches each on a normal day.

QuantityWorkingResult
Pods1,500 services x 4060,000
Average line rate60,000 x 6 / s360,000 / s
Burst line rate3x during incidents and deploysabout 1.1M / s
Raw ingest bandwidth360K x 380 Babout 137 MB / s
Raw volume per day137 MB / s x 86,400about 11.8 TB
Compressed per dayzstd on sorted chunks, about 9:1about 1.3 TB
Hot tier, 3 days on SSD1.3 TB x 3 x 2 replicasabout 7.8 TB
Object storage, 1 year1.3 TB x 365, durability handled by the storeabout 475 TB
Searches per day4,000 engineers x about 12, plus dashboardsabout 80,000, roughly 1 / s
Lines written per line read31B written a day, a tiny fraction ever returnedwell over 1,000 : 1
The number that shapes everything

31 billion lines are written each day and perhaps a few million are ever shown to a person. Any work done per line at write time is multiplied by 31 billion; work done at read time is multiplied by about 80,000. That asymmetry argues for cheap writes and smarter reads.

What it costs

475 TB in object storage is roughly $11,000 a month at standard list prices, and less with infrequent-access classes. A full inverted index over the same data would typically be one to two times the compressed size again, on fast disks, which is where log platforms usually blow their budget.

04

Interfaces

04

Interfaces

Three audiences call the platform: agents writing, people searching, and people watching. Each gets an interface shaped for it.

POST/ingest/v1/batchesAgent push, zstd compressed
// Authorization: Bearer <cluster ingest token> // Content-Encoding: zstd, batches of up to 1 MB or 500 ms { "agent": { "node": "ip-10-4-17-88", "seq": 88120477 }, "streams": [{ "labels": { "cluster": "prod-ap1", "ns": "payments", "service": "charge-api", "level": "error" }, "lines": [ [1790001234567000123, "{\"msg\":\"card declined\",\"order\":\"ORD-77120\",\"trace_id\":\"9c1e2f\"}"] ] }] } // 204 once durably in Kafka; 429 with Retry-After when a tenant is over quota
GET/query/v1/search?q=...&from=...&to=...&limit=500Search a time range
// q = {ns="payments", level="error"} |= "ORD-77120" | json | latency_ms > 800 { "lines": [{ "ts": "2026-10-01T08:14:03.112Z", "labels": { "service": "charge-api" }, "line": "..." }], "stats": { "chunks_scanned": 412, "chunks_skipped_by_bloom": 9310, "bytes_read": "1.9 GB", "ms": 1840 }, "next_cursor": "c_2026-10-01T08:14:03.112Z_7" }
GET/query/v1/tail?q={service="checkout"}Live tail over WebSocket
// server pushes frames as lines arrive; drops and reports a count if the client is slow { "lines": [ ... ], "dropped": 0 }
POST/rules/v1/alertsCreate an alert rule
{ "name": "charge-declines-spike", "expr": "count_over_time({service=\"charge-api\"} |= \"declined\" [5m]) > 2000", "for": "3m", "notify": ["pager:payments-oncall"] }

05

Pipeline layout

05

Pipeline layout

Collect, buffer, store, queryDiagram
flowchart LR
	AG["Node agent with disk buffer"] --> GW["Ingest gateway"]
	GW --> K[["Kafka, partitioned by stream"]]
	K --> ING["Ingesters build chunks"]
	K --> TAIL["Tail fan-out"]
	K --> RULE["Rule evaluator"]
	ING --> HOT[("Hot chunks on SSD, 3 days")]
	ING --> OBJ[("Object storage, chunks and blooms")]
	ING --> IDX[("Label index")]
	Q["Query frontend"] --> QW["Querier pool"]
	QW --> IDX
	QW --> HOT
	QW --> OBJ
	TAIL --> UI["Engineer UI"]
	Q --> UI

The pipeline has one rule: every stage can fall behind without hurting the stage before it. The agent buffers to local disk, the gateway acknowledges once Kafka has the batch, and ingesters, tail and alerts read from Kafka at their own pace. A slow indexer delays search; it never makes a checkout service block on a log write.

The agent runs as a daemon on each node, something like Fluent Bit or Vector. It tails container log files, parses JSON, adds Kubernetes labels, batches by size or time, compresses with zstd, and posts to the gateway. It keeps up to 2 GB of unsent data on disk, which covers about an hour of a busy node, and it numbers each batch so the gateway can drop repeats after a retry.

The gateway authenticates the cluster, applies per-tenant rate limits, rejects label sets that would explode cardinality, and writes to Kafka keyed by stream, where a stream is one unique label set. Keying by stream keeps one pod's lines in order on one partition. Kafka holds 24 hours of data, about 12 TB raw, so the downstream stages can be redeployed or rebuilt without data loss.

Ingesters consume partitions and accumulate lines per stream in memory. When a stream's buffer reaches about 1.5 MB compressed or 15 minutes of age, the ingester sorts it by time, compresses it into a chunk, builds a small bloom filter of the tokens in it, writes both to object storage, and records the chunk in the label index. Recent chunks also stay on local SSD for the hot tier. Until a chunk is flushed, queriers ask the ingesters directly for the newest lines, which is how search stays within 10 seconds of real time.

The query path splits a request into time slices, looks up candidate chunks in the label index, drops chunks whose bloom filter rules out the search term, and fans the remaining chunks out across a pool of stateless queriers that decompress and scan them in parallel. Results merge by timestamp and stream back to the UI.

Label index

Maps each label set and time bucket to its chunk IDs. Kept small on purpose, since it indexes streams, not words, so it fits in a few hundred GB for a year of data.

Tail fan-out

Reads Kafka, matches lines against active tail sessions, and pushes them over WebSockets. It is best effort: a slow browser gets lines dropped and a counter, never backpressure into the pipeline.

Rule evaluator

Streams lines through compiled matchers and keeps sliding-window counters per rule. Evaluating on the stream, not by running searches every minute, keeps alerting off the query path.

Compactor

Merges small chunks from quiet streams, rewrites chunks for delete requests, enforces retention, and moves older data to cheaper storage classes.

06

Deep dive: storing and finding 31 billion lines a day

06

Deep dive: storing and finding 31 billion lines a day

The core question

Do you build a full-text index over every word at write time, or store compressed chunks with a light index and do the heavy work at read time? The answer decides cost, ingest complexity and query speed.

Why the obvious stores fail

A relational table with one row per line dies on write volume and index maintenance long before a year of data is in it, and a LIKE '%timeout%' query cannot use a B-tree index at all. A general search engine with an inverted index over every token handles queries beautifully, but every line is tokenized, every token appended to posting lists, and segments are merged in the background, all on SSD with replicas. At 360,000 lines a second you end up running a very large cluster mostly to index text that will never be searched.

Chunks, labels and blooms

The chosen layout treats a log stream like a time series of text. Lines are grouped by stream and time into chunks of a few megabytes, compressed together, which works well because neighbouring lines from one pod are very repetitive. The only index is over labels and time: which chunks belong to service=charge-api, level=error between 08:00 and 09:00. That index is tiny because there are perhaps a few million active streams, not trillions of words.

Free-text search then becomes a parallel scan of the chunks that survive the label filter. Two tricks make that scan cheap. First, each chunk carries a bloom filter of its tokens, around 1% of its size, so a search for a rare string like an order ID can skip most chunks without downloading them. Second, the scan itself is embarrassingly parallel: a querier can decompress and grep about 1 GB of raw text per core per second, so 200 querier cores read a 30-minute slice of a large service in well under a second.

A search, from query to merged resultsDiagram
flowchart TD
	Q["Search: payments errors containing ORD-77120, last 6 h"] --> S["Split into 15-minute slices"]
	S --> L["Label index: 9,700 candidate chunks"]
	L --> B{"Bloom says token may be present?"}
	B -->|no| SK["Skip chunk, about 96% of them"]
	B -->|yes| F["Fetch about 400 chunks, from SSD or object store"]
	F --> G["Queriers decompress and match in parallel"]
	G --> I["Ask ingesters for unflushed lines"]
	I --> M["Merge by timestamp, return first 500"]

Label cardinality is the thing to police

This design depends on labels describing where a line came from, not what it says. If a team puts a user ID or request ID in a label, every user becomes its own stream, chunks shrink to a handful of lines, the index balloons and compression collapses. The gateway enforces limits, such as at most 15 labels per stream and at most 50,000 active streams per service, and rejects or folds offenders with a clear error. High-cardinality values belong inside the line, where the bloom filter and the scan can find them.

Making common fields fast

Pure scanning is fine for needle searches but slow for questions like "p95 latency by endpoint over a week". For those, a small set of well-known JSON fields such as status, latency_ms and route can be extracted at ingest into a columnar side table in Parquet or ClickHouse. That gives fast aggregations for the 10 fields everyone uses without paying to index the other 500.

Tiers and retention

TierWhereAgeTypical query speed
In memoryIngester buffers, replicated three waysLast 15 minutesMilliseconds
HotLocal NVMe on ingesters and a chunk cacheUp to 3 daysUnder 3 s for an hour
StandardObject storage, standard class3 to 30 daysSeconds to tens of seconds
ArchiveObject storage, infrequent or archive class30 days to 1 yearMinutes, run as a background job

07

Design choices weighed

07

Design choices weighed

Chosen
Label index plus scanned chunks

Ingest is cheap and storage is mostly object store, so cost scales with bytes, not with words. Needle searches over long ranges are slower than an inverted index, and teams must keep labels low-cardinality.

Best cost per GB
Alternative
Full inverted index on every token

Fast, flexible search with relevance ranking and rich aggregations. Indexing typically costs several times more compute and storage, and the cluster needs constant care as segments merge. Worth it for a security team that runs heavy ad hoc queries, often on a subset of logs.

For high-value subsets
Chosen
Agents push to a gateway

New nodes start shipping logs without central configuration, batching happens at the source, and the agent's disk buffer rides out pipeline outages. You must protect the gateway from a flood with quotas.

Simple to run at fleet scale
Rejected
A central collector pulls from hosts

Gives the platform control over load, which is why metrics systems often scrape. For logs it adds polling delay, requires discovering 60,000 short-lived pods, and loses data when a pod dies between polls.

Wrong fit for logs
Chosen
Kafka as the durable buffer

Decouples producers from every consumer, lets search, tail and alerts read the same data independently, and allows replay after a bug. Costs a cluster that holds 24 hours of raw logs.

Absorbs bursts and mistakes
Alternative
Gateway writes straight to ingesters

Fewer moving parts and lower latency. Ingesters then need their own replication and write-ahead log to avoid losing data, and a slow ingester pushes back on agents directly. Loki's own design does roughly this, so it is viable with care.

Viable, more coupling
Chosen
Alerts on the stream

Rules see every line within seconds and do not compete with humans for querier capacity. Rules can only use windowed counts and simple matches, not arbitrary joins.

Fast and isolated
Rejected
Alerts as scheduled searches

Any search can become an alert, which is flexible. But 3,000 rules each searching every minute would become the platform's largest query load and get slower exactly during incidents.

Fails under load

08

What goes wrong

08

What goes wrong

📈
A bad deploy logs a stack trace per request

One service starts writing 40 lines per request and its volume jumps 30x in a minute. Without limits, it fills Kafka partitions, slows ingesters for everyone, and the logs needed to debug every other service arrive late.

Per-tenant and per-stream rate limits at the gateway, a 429 that agents respect by buffering locally, and sampling of repeated identical lines with a "repeated N times" marker.
🐢
Ingesters fall behind Kafka

An object storage slowdown makes chunk flushes take seconds. Consumer lag climbs to ten minutes. Live tail looks fine because it reads Kafka directly, but search is missing recent lines, which confuses people mid-incident.

Alert on consumer lag in seconds, show a "search is N minutes behind" banner in the UI, autoscale ingesters on lag, and keep 24 hours in Kafka so catching up is just time.
🧨
Label cardinality explosion

A team adds request_id as a label. Active streams jump from 3 million to 400 million in an hour, ingester memory runs out, and the label index grows faster than it can be compacted.

Hard limits on labels per stream and streams per service, enforced at the gateway with a descriptive error, plus a dashboard of the top cardinality offenders.
🔎
One giant query starves the cluster

Someone searches 30 days of every namespace for the word "error". The query plans 40 million chunk reads and occupies every querier for minutes during an incident.

The query frontend estimates bytes before running, enforces per-user concurrency and byte limits, queues large scans behind interactive ones, and caches results per time slice so retries are cheap.
🕳️
An agent dies with unsent data

A node runs out of disk and the agent cannot write its buffer, or the node is terminated before the agent flushes. The last few seconds of logs from that node, often the most interesting ones, are lost.

Reserve buffer space with a quota, flush on SIGTERM during node drain, and report agent drop counters as a metric so silent loss is visible.
🔐
Secrets and personal data in logs

A debug line prints a full card number or an access token. It is now copied into Kafka, ingesters, the hot tier and a year of archives.

Redaction rules in the agent and the gateway for known patterns, access controls by namespace, and a compactor job that can rewrite chunks to delete matching lines on request.

09

How to run the interview

09

How to run the interview

1

Begin with grep on one box. Then name what breaks: ephemeral containers, scattered request paths, no alerting. Each part of the pipeline answers one of those.

2

Say the write-to-read ratio early. When billions of lines are written for every few thousand searches, write-time work is the expensive kind. That one sentence justifies the storage design.

3

Put a buffer in the middle and explain why. Kafka protects producers from slow consumers and makes replay possible. Mention the local disk buffer in the agent too.

4

Compare inverted index with chunk scanning. Know the cost of each and when you would pick the expensive one anyway, such as security analytics on a smaller subset.

5

Bring up cardinality. It is the most common way real log platforms fall over, and interviewers who have run one will light up when you mention it.

6

Separate tail, search and alerts. Three consumers of the same stream with different guarantees: best-effort, eventually complete, and continuously evaluated.

7

Talk about cost and retention. Tiered storage, per-namespace retention and sampling of noisy logs show you have seen the invoice.

10

Growing from files to a platform

11

Growing from files to a platform

Stage 1: a few services, under 5,000 lines a second
Ship files to one box

Agents send logs to a single server that writes hourly files per service. Search is a parallel grep. It is crude and perfectly fine for a small team.

Stage 2: tens of thousands of lines a second
A managed search cluster

Adopt a hosted search engine with a short retention, often 7 days. Search becomes fast and flexible, and cost grows linearly with volume.

Stage 3: hundreds of thousands a second
Kafka in the middle, chunks in object storage

The search bill becomes the problem. Insert Kafka as a buffer, move bulk storage to compressed chunks with a label index, and keep a full-text index only for the namespaces that need it.

Stage 4: a million lines a second and a year of retention
Tiers, blooms and a query frontend

Add bloom filters per chunk, storage tiers, a query frontend with slicing, caching and limits, and streaming alert rules. Gateway limits on cardinality become mandatory.

Stage 5: many regions
Store locally, query globally

Keep logs in the region that produced them for cost and data residency, and let a global query frontend fan out to regional queriers. Only results cross regions, never raw chunks.

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.