Design problems
AI-era systemsHardSDE-3 35 min

Design an LLM evaluation and observability platform

Asked at
AtlassianFreshworksGoogleMicrosoftOpenAISalesforce

The interviewer asks

“Design an internal platform (think Langfuse or LangSmith) that records every LLM call made by the company's products, lets engineers debug a bad answer from its trace, scores quality automatically in production, and blocks a prompt or model change in CI if it makes quality worse.”

What a strong answer looks like

A strong answer splits the problem into two halves that share one data model: observability (capture every trace cheaply and never slow the product down) and evaluation (score quality offline on datasets and online on sampled production traffic). Capture is an async SDK or OpenTelemetry exporter that batches spans to an ingestion API, which writes to Kafka; small metadata (model, tokens, latency, cost, scores) goes to ClickHouse and large prompt/response bodies go to S3, after PII redaction. Evaluation runs as queued jobs: deterministic checks first (JSON schema, regex, citations present), then LLM-as-judge with a calibrated rubric, then human annotation queues for the cases the judge is unsure about. Prompts and datasets are versioned, and a CI gate compares a candidate against the baseline with confidence intervals, not single averages. Senior candidates talk about judge calibration against human labels, sampling and cost of evaluation itself, tenant isolation of prompts, and replaying a trace against a new model.

Concepts to mention

async non-blocking SDK with bounded bufferOpenTelemetry trace context and GenAI span attributesKafka buffer between ingest and storageClickHouse for metadata, S3 for prompt bodiesPII redaction before storageversioned prompts, datasets and evaluatorsLLM-as-judge with calibration against human labelssampling for online evaluationpaired comparison with confidence intervals in CItrace replay against another model

1 Requirements

Functional

  • Ingest traces from all services: spans for HTTP, retrieval, tool calls and LLM calls, with prompt, response, model, tokens, latency and cost
  • Trace explorer: search by user, session, feature, model, score, error or time; open one trace and see the full tree with exact prompts
  • Prompt registry: versioned prompts with labels (production, staging) fetched by services at runtime
  • Datasets: curated input/expected-output sets, created by hand or by saving production traces
  • Offline experiments: run a prompt/model version over a dataset and score it with evaluators
  • Online evaluation: score a sample of live traffic with LLM-as-judge and cheap heuristic checks
  • Human annotation queues with rubrics, to label traces and calibrate automated judges
  • CI gate: fail a pull request when a candidate is significantly worse than the baseline on key metrics
  • Replay: re-run a stored trace's exact prompt against another model or prompt version

Non-functional

  • Zero impact on product latency: SDK adds < 1 ms on the request path and never blocks or throws
  • Ingest 50M LLM calls/day (~250M spans/day), peak 10k spans/s during sales events
  • Trace visible in the explorer within 30 s (p95) of the call
  • Trace search p95 < 2 s over 30 days of data; aggregates over 90 days in < 5 s
  • No raw PII in long-term storage: the SDK masks known sensitive fields, and pattern/NER redaction runs before anything reaches S3 or ClickHouse; data stays in the India region for Indian tenants
  • Ingestion availability 99.9%; if the platform is down, products keep working and spans are dropped or buffered, never retried in the request path
  • Retention: full bodies 30 days, metadata and scores 13 months

Out of scope (say it out loud)

  • Training or fine-tuning models
  • The LLM gateway itself (we consume its usage events)
  • Billing customers for usage
  • General infra APM (CPU, pod metrics) beyond linking to it by trace id

2 Estimates

Span ingest rate~2,900/s avg, ~10k/s peak50M LLM calls/day × ~5 spans per trace (request, retrieval, rerank, LLM, tool) = 250M spans/day ÷ 86,400 ≈ 2,900/s; ×3-4 at peak
Raw body volume~400 GB/day before compressionAvg prompt ~2,000 tokens ≈ 8 KB of text, response ~1 KB → ~8 KB per LLM call (some calls share prompts, many are shorter) × 50M ≈ 400 GB/day
Stored body volume (30 days)~2.5-3 TB in S3zstd on text gives ~4-5× → ~90 GB/day × 30 days ≈ 2.7 TB. Dedup of repeated system prompts by hash cuts it further
Metadata volume (13 months)~10 TB raw → ~1-1.5 TB in ClickHouse250M spans × ~100 B of columns = 25 GB/day × 395 days ≈ 10 TB; columnar compression 8-10× on repetitive fields
Online judge cost~$600/day (~₹50k) at 2% sampling1M judged traces/day × (~3k input + 200 output tokens) on a small judge model at roughly $0.15/1M input, $0.60/1M output ≈ $0.0006 per judgement → ~$600/day before caching. Judging 100% would cost ~$30k/day, so sampling is mandatory
CI experiment size~800-1,000 examples per gatePaired test (McNemar-style): if ~10% of items flip between baseline and candidate, n ≈ (1.96 + 0.84)² × 0.10 ÷ 0.03² ≈ 870 items to catch a 3-point drop with 80% power. Unpaired you would need ~2,800 per arm. 100 items only catch 10-point drops

3 API

EndpointWhat it does
POST/v1/ingest/batchSDK/OTel exporter sends up to 500 spans or 4 MB per call, gzip. Returns 207 with per-item status; idempotent by span_id.
POST/v1/otlp/v1/tracesStandard OTLP endpoint so services using the OpenTelemetry Collector and GenAI semantic conventions can send without our SDK.
GET/v1/traces?feature=&model=&min_latency=&score_lt=&from=&to=&cursor=Search traces (served from ClickHouse); returns metadata only.
GET/v1/traces/{trace_id}Full trace tree; bodies fetched from S3 by reference and shown after a permission check.
GET/v1/prompts/{name}?label=productionRuntime prompt fetch; services cache it locally for 60 s and fall back to the last good copy if the platform is down.
POST/v1/experimentsStart an offline run: {dataset_id, dataset_version, prompt_version, model, evaluators[]}. Returns experiment_id; progress via polling.
POST/v1/scoresAttach a score to a trace or span from any source: user thumbs up/down, judge, human annotator, heuristic.
POST/v1/gates/{gate_id}/checkCalled from CI with a candidate config; runs or reuses an experiment and returns pass/fail with metric deltas and confidence intervals.

4 Data model

spansClickHouse (MergeTree, partitioned by day)
tenant_idproject_idtrace_idspan_idparent_span_idnamekind (llm|retrieval|tool|http)start_tsduration_msmodelprompt_versioninput_tokensoutput_tokenscached_tokenscost_usdstatuserror_typeuser_hashsession_idfeaturebody_refattributes (Map)

Columnar store for fast filters and aggregates over billions of rows. ORDER BY (tenant_id, project_id, toDate(start_ts), trace_id). TTL 13 months. ReplacingMergeTree keyed by span_id absorbs duplicate retries.

bodiesS3 (Mumbai region for Indian tenants)
key = tenant/date/trace_id/span_id.json.zst → redacted prompt messagesresponseretrieved chunk idstool args/results

Large, rarely read, cheap to keep; 30-day lifecycle rule. System prompts stored once by content hash and referenced, which removes most duplicate bytes.

scoresClickHouse
score_idtenant_idtrace_idspan_idname (faithfulness, helpfulness, json_valid, thumbs)valuesource (judge|human|heuristic|user)evaluator_versioncommentcreated_at

Kept separate from spans because scores arrive late (minutes to days) and many per trace. Joined by trace_id in queries.

promptsPostgres
prompt_idnameversiontemplatemodel_params (json)labels[]created_bycreated_atcommit_sha

Small, transactional, needs unique (name, version) and atomic label moves (promote v12 to production). Cached in Redis for runtime fetch.

datasets / dataset_itemsPostgres + S3
dataset_idversionitem_idinput (json)expected (json)tagssource_trace_id

Datasets are immutable once versioned so experiments are reproducible; big inputs go to S3.

experiments / experiment_resultsPostgres (run state) + ClickHouse (per-item results)
experiment_iddataset_versionprompt_versionmodelevaluator_versionsstatus; per item: output_refscoreslatencycost

Run state needs transactions; per-item rows need fast aggregation and paired comparison with the baseline.

annotation_queuePostgres
item_idqueue_idtrace_idrubric_idassigned_tostatuslabelcreated_at

SELECT ... FOR UPDATE SKIP LOCKED to hand out work to annotators without double assignment.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALbatched spansraw spansconsumeredacted bodiesclean spansbulk insert2% samplejudge promptscoressearch, CI gatequeriespromptsstart runcandidate + judgefetch promptProduct services + SDKEval UI / CI pipelineIngest API (OTLP)Query + prompts APIOnline judge workersRedaction + enrichExperiment runnerKafka: spans, scoresBatch writersS3 prompt bodiesClickHousespans/scoresPostgresprompts/datasetsLLM gateway (judges)
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Capture one LLM call

  1. 1The support bot calls the LLM gateway; the SDK opens a span with trace_id from the incoming traceparent header and records model, prompt_version and timings.
  2. 2On completion the SDK puts the span into an in-memory ring buffer (bounded, e.g. 10k spans) and returns immediately; nothing is sent on the request path.
  3. 3A background thread flushes every 1 s or 500 spans as one gzip batch to the ingest API with a 2 s timeout; if the buffer is full, the oldest spans are dropped and a dropped-spans counter increments.
  4. 4The ingest API authenticates the project key, validates size, writes the batch to Kafka (key = trace_id, so one trace stays in one partition) and returns 207.
  5. 5Redaction workers tokenise emails, phone numbers, PAN/Aadhaar-like IDs and card numbers, compute cost from the versioned price table, and upload the body to S3.
  6. 6Batch writers insert ~50k rows at a time into ClickHouse every few seconds (ClickHouse prefers few large inserts over many small ones).
  7. 7The trace appears in the explorer within ~10-30 s.

Online quality scoring

  1. 1A sampler consumer reads spans from Kafka and picks traces by rule: 2% of all traffic, 100% of traces with negative user feedback, 100% of a newly launched prompt version for 24 h.
  2. 2Cheap deterministic evaluators run first: response parses as the expected JSON schema, every cited chunk id exists in the retrieved set, no banned phrases, length limits.
  3. 3For sampled traces, the judge worker builds a rubric prompt (question, retrieved context, answer) and calls a judge model through the LLM gateway with temperature 0.
  4. 4The judge returns a structured verdict (e.g. faithfulness 1-5 plus a one-line reason); the worker writes it as a score event to Kafka, which lands in ClickHouse.
  5. 5Low-confidence or disagreeing verdicts are pushed into a human annotation queue; those human labels later measure the judge's agreement rate.
  6. 6Dashboards show score trends per feature and prompt version; a burn-rate style alert fires if faithfulness falls below its target for an hour.

CI regression gate for a prompt change

  1. 1A pull request changes the summarisation prompt; CI calls POST /gates/summary-quality/check with the new prompt text.
  2. 2The experiment runner loads the pinned dataset version (900 items) and runs the candidate through the gateway with bounded concurrency (e.g. 50 in flight) to stay inside rate limits.
  3. 3Each output is scored by the same evaluator versions used for the baseline; baseline outputs are cached, so only the candidate costs money.
  4. 4The runner computes paired differences per item and a bootstrap 95% confidence interval for each metric delta.
  5. 5The gate fails if any key metric's interval is entirely below the allowed drop (e.g. faithfulness worse than -2 points) or if cost or p95 latency rise by more than the budget.
  6. 6CI posts a report with the 10 items that got worst, each linking to its trace for side-by-side review.

7 Deep dives

Ingestion that can never hurt the product

The first rule of an observability platform is that it must fail silently. The SDK writes spans to a bounded in-memory buffer and flushes from a background worker; the request thread never waits on the network, never retries and never throws. If the buffer fills (platform down, network slow), it drops spans and counts the drops so we know data is incomplete. The ingest API is stateless and does almost nothing: authenticate, check size limits, write to Kafka, return. Kafka absorbs bursts (a festive sale can triple traffic) and lets redaction and storage workers fall behind for minutes without losing data, with 24-72 h retention as a safety net. Partitioning by trace_id keeps spans of one trace together, which helps late-arriving children join their parent. Writes to ClickHouse are batched into large inserts because each insert creates a data part, and thousands of tiny inserts per second cause 'too many parts' errors. Duplicates from SDK or Kafka retries are expected (at-least-once); span_id is the dedupe key and ReplacingMergeTree collapses duplicates during merges, while queries that must be exact use FINAL or argMax. For tail sampling of non-LLM spans, run it in an OpenTelemetry Collector tier: keep 100% of errors, slow traces and LLM spans, and 10% of the rest.

Making LLM-as-judge trustworthy

An LLM judge is a model too, so it has bias and noise. Known problems: position bias in pairwise comparisons (prefers the first answer), verbosity bias (prefers longer answers), self-preference (a model rates its own family's outputs higher), and drift when the judge model is upgraded. Mitigations: write narrow rubrics with concrete criteria and examples rather than 'rate quality 1-10'; prefer binary or 3-level labels (supported / partially / unsupported) which are more consistent than fine scales; for pairwise, run both orders and only count consistent wins; use a judge from a different model family than the generator where possible; pin the judge model and prompt as an evaluator_version so scores are comparable over time. Most importantly, calibrate: take 200-300 traces, have two human annotators label them, measure human-human agreement, then judge-human agreement (Cohen's kappa or simple accuracy). If the judge agrees with humans about as often as humans agree with each other, use it; if not, fix the rubric. Re-run this calibration whenever the judge model changes. Deterministic checks (schema valid, citation exists, SQL executes) should always run before the judge, because they are free and never wrong.

Storage split: ClickHouse for metadata, S3 for bodies

Engineers ask two kinds of questions. Aggregate questions: 'p95 latency and average faithfulness by prompt version for the last 7 days', 'which tenants had the most JSON parse failures'. These scan millions of rows but only a few columns, which is exactly what a columnar store like ClickHouse is good at: it reads only the needed columns, compresses repeated values like model names very well, and aggregates billions of rows in seconds. Lookup questions: 'show me the exact prompt for this trace'. These need large text blobs but only for a handful of traces. Putting 8 KB bodies into ClickHouse rows would bloat every scan and the storage bill; putting them in S3 costs about $0.025 per GB-month and is read only when someone opens a trace. The span row keeps a body_ref pointer. Full-text search over prompt bodies is a real request ('find answers mentioning a refund of more than ₹5,000'); handle it with a small token index or ngram bloom filter in ClickHouse on a truncated preview column (first 1 KB) rather than indexing all bodies in Elasticsearch, which would be the most expensive part of the system. Postgres holds the small, transactional things: prompts, datasets, experiments, annotation queues.

A CI gate that is statistically honest

LLM outputs are noisy, so comparing two averages from one run is misleading: a 1-point drop on 100 examples is usually noise. Make the comparison paired: run baseline and candidate on the same items and look at per-item differences, which removes most item-difficulty variance. Use a bootstrap over items to get a 95% confidence interval for the delta, and define the gate as 'fail if the interval is entirely below -X', where X is the drop the product team agreed is acceptable. Size the dataset for the effect you want to catch: with paired items and ~10% of items changing outcome, roughly 800-900 items are needed to reliably detect a 3-point drop; 100 items only catch large regressions. Freeze everything that is not under test: dataset version, evaluator versions, judge model, temperature 0, and cache baseline outputs so each run only pays for the candidate. Slice results by tag (Hindi queries, long documents, refund questions) because an overall average can hide a large regression on one slice. Gate on cost and latency too: a prompt that is 2% better but 40% more expensive should be a conscious decision, not an accident.

8 Trade-offs

Build on OpenTelemetry vs a custom SDK

OTel with the GenAI semantic conventions gives vendor-neutral spans, auto-instrumentation for HTTP, Kafka and databases, and context propagation for free, so LLM spans sit in the same trace as everything else. A thin custom SDK is still useful for LLM-specific helpers (prompt fetch with caching, scoring from code, dataset capture). Best of both: accept OTLP at ingest and ship a small SDK that wraps OTel rather than replacing it.

Store full prompts vs metadata only

Full prompts make debugging possible: most bad answers come from bad context (wrong chunks, truncated history), which metadata cannot show. The cost is storage and privacy risk. Store redacted full bodies for 30 days, metadata for 13 months, and allow tenants or features handling sensitive data (health, finance) to opt into metadata-only capture.

Online judging: sample vs everything

Judging 100% of traffic can cost as much as the product's own LLM bill. Sampling 1-5% gives stable daily trends for each feature; targeted sampling (all negative-feedback traces, all traffic of a new prompt version for its first day) puts the budget where the risk is. Cheap heuristics run on 100%.

Self-host vs SaaS (Langfuse Cloud, LangSmith, Braintrust)

SaaS is faster to adopt, but prompts contain customer data, and Indian regulated businesses (RBI-regulated lenders, payment companies) often must keep data in India. Self-hosting an open-source core like Langfuse on our own ClickHouse, S3 and Postgres keeps data in-region and costs engineers' time to operate.

9 Bottlenecks

ClickHouse insert pressure at 10x traffic

At 30k spans/s, many small inserts create too many parts and merges fall behind. Increase batch size and interval (100k rows or 5 s), use async inserts, and add shards keyed by tenant_id hash. Kafka gives the buffer while you scale.

Noisy tenant floods the platform

One team's agent in a retry loop emits 50× normal spans. Enforce per-project ingest quotas at the ingest API (token bucket in Redis; return 429 which the SDK treats as 'drop and count'), so one tenant's bug cannot delay everyone else's traces.

Judge and experiment cost grows with adoption

As 50 teams add evaluators, judge spend can exceed budget. Give each project an evaluation budget, cache judge results by (evaluator_version, input hash, output hash), and push teams toward deterministic checks.

10 Failure handling

Platform outage during a sale

SDK buffers fill and drop spans; products are unaffected because nothing is on the request path. Prompt fetch uses the locally cached last-known-good version, so a platform outage never blocks a prompt load. When ingest recovers, Kafka consumers catch up; the dropped-span counter tells us exactly which period has gaps.

Redaction worker bug lets PII through

Redaction happens before bodies reach S3 or ClickHouse, so a bug means raw PII is stored. Detect it with a canary: synthetic traces containing known fake PII are sent every minute and an alert fires if they are found unredacted. Remediation is a job that re-redacts or deletes the affected S3 objects and ClickHouse rows for that time range. Keep the raw Kafka topic's retention short (24-72 h) and encrypted, because it is the one place where unredacted data lives by design.

Judge model is upgraded or deprecated by the provider

Scores shift overnight and dashboards show a fake regression. Pin judge model versions in evaluator_version, run the old and new judge side by side on the calibration set before switching, and annotate dashboards at the switch point.

11 What impresses

  • States up front that the platform must never add latency or failures to the product, and designs the SDK around that
  • Separates aggregate queries (columnar store) from blob lookups (object storage) with a cost argument
  • Knows judge biases (position, verbosity, self-preference) and proposes measuring judge-human agreement
  • Sizes the evaluation dataset from the effect size it must detect instead of picking '100 examples'
  • Puts cost and latency in the CI gate next to quality
  • Handles data residency and PII for Indian regulated tenants

12 Mistakes

  • Sending traces synchronously from the request path or retrying them inline
  • Storing full prompt bodies in the same row store used for dashboards
  • Trusting an uncalibrated LLM judge score as ground truth
  • Comparing single-run averages on a tiny dataset and calling it a regression test
  • Ignoring PII in prompts and shipping it to a third-party tool
  • Not versioning prompts, datasets and evaluators, so results cannot be reproduced

13 Follow-ups

Q1How would you evaluate a multi-step agent where only the final outcome is known?
Q2How do you detect quality drift when there is no labelled data in production?
Q3A team says the judge disagrees with users' thumbs-down. How do you investigate?
Q4How would you let tenants delete their data on request (right to erasure) across ClickHouse and S3?
Q5How do you keep trace context across a Kafka hop and a Temporal workflow?
Q6What would change if the platform had to support 1B calls/day?

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.

Start free