Observability and Monitoring Architecture Questions
Building visibility into infrastructure and services: metrics, logs, and traces, dashboards and alerting, SLIs/SLOs, and the design of an observability stack. Covers instrumenting systems for actionable signal, reducing alert noise, and diagnosing production issues from telemetry. Infrastructure-wide observability, distinct from network-specific monitoring.
Design a cross-signal correlation index that lets an engineer jump quickly from a metric anomaly to the relevant logs and traces for root-cause analysis. What identifiers would you require every signal to carry, how would you build and maintain that mapping, and how would you keep queries across systems fast at scale, including when an identifier is missing?
Sample Answer
Direct answer
Require every signal (metric, log line, trace span) to carry a small set of common identifiers, most importantly trace_id (or a request/correlation ID where full tracing isn't wired up yet), plus service_name and a timestamp. Build a lightweight mapping index, keyed by those identifiers, that points to where each related log or span physically lives (shard, offset, time range) rather than storing the events themselves twice. When an identifier is missing, fall back to narrowing by time window and service first, then use a scored, probabilistic match on top of that narrowed candidate set instead of searching the whole corpus.
Structured elaboration
Required identifiers per event:
trace_id/span_id: the strong identifier, present when full distributed tracing is instrumented.service_name,host_or_pod_id: contextual keys usable even when tracing isn't present.event_timestamp: needed for the time-window fallback and for ordering.metric_dimensions/anomaly_id: on the metrics side, the anomaly detector emits an anomaly document carrying whatever identifiers were present on the underlying series.
Index structure, not a duplicate copy: the mapping store is a pointer index (topic/partition/offset for logs, trace/span ID for traces), not a second copy of log or trace bodies. Logs and traces stay in their existing systems (search index, trace store); the correlation index only tells the query which shard and offset to fetch.
Query flow:
- Direct ID lookup, if the anomaly carries a
trace_id: O(1) pointer lookup into the mapping store. - Indexed search, if not: query the log/trace index filtered by
service_nameand a time window around the anomaly. - Probabilistic fallback, if identifiers are sparse: score candidates from step 2 by temporal proximity, shared host, and payload fingerprint, and return ranked results with a visible confidence, not a silent best guess.
flowchart LR
A[Metric anomaly] --> ID{trace_id present?}
ID -- yes --> M[(Mapping index: id -> pointer)]
M --> L1[Fetch log/trace by pointer]
ID -- no --> TW[Filter: service + time window]
TW --> SC[Score candidates: proximity, host, fingerprint]
SC --> L2[Ranked candidates + confidence]
Maintaining the mapping: on ingest, if a strong identifier is present, upsert a bounded (capped) pointer list into the mapping store for that ID and set a TTL matching the signal's own retention. A background job prunes expired entries so the index doesn't grow unbounded past what the underlying logs and traces themselves retain.
Worked example
Index sizing. Assume the cluster produces 50,000 events/sec across logs and trace spans combined:
eventsPerDay=50,000×86,400=4.32×109Each mapping entry (topic, partition, offset, timestamp, service ID) is about 40 bytes:
indexBytesPerDay=4.32×109×40 bytes=172.8 GB/dayAt a 7-day retention window matching typical hot log retention:
indexBytesRetention=172.8×7=1,209.6 GB≈1.21 TBThis is the number that justifies capped pointer lists and TTL-based pruning in the design above: an unbounded, uncapped index at this event rate would grow past a terabyte inside a week even though it stores no event bodies, only pointers.
Fallback candidate-set size when the identifier is missing. With 200 services sharing the 4.32×109 daily events roughly evenly:
eventsPerServicePerDay=2004.32×109=21,600,000Narrowing to a ±2.5s window (5s total) around the anomaly timestamp before service-level scoring:
timeReductionFactor=586,400=17,280 candidateSetSize=17,28021,600,000=1,250 eventsFiltering by service and a 5-second window before running any fuzzy scoring shrinks the search space from 21.6 million events/day for that service down to about 1,250 candidates, small enough for a scoring pass to run against on every anomaly without scanning the full day's log volume.
Trade-offs & pitfalls
| Fallback stage | Precision | Cost | When it's needed |
|---|---|---|---|
| Direct ID lookup | Exact | O(1) | trace_id present (the common case in fully-instrumented services) |
| Service + time window | Approximate, but bounded | O(candidates), here ~1,250 | trace_id missing, service known |
| Fuzzy scoring | Ranked, with confidence | O(candidates) scoring passes | trace_id missing and multiple plausible matches remain |
Common wrong turns: treating the mapping index as a full copy of the underlying signals instead of a pointer index (this doubles storage for no correlation benefit and creates a second source of truth to keep consistent); running the fuzzy-scoring pass over the entire day's events instead of narrowing by service and time window first, which turns an O(1,250) scoring problem into an O(21,600,000) one for no accuracy gain; and silently returning the single top-scored fuzzy match without surfacing its confidence, which looks like a correlation but is actually a guess an on-call engineer has no way to sanity-check.
You need trace correlation to work reliably across 1,000 microservices written in multiple languages: every trace needs a unique ID and a standardized propagation header, with minimal runtime overhead. Some services still use legacy, non-standard headers. Design the migration and enforcement approach: how do you get every SDK onto the standard, and how do you handle a request that shows up with missing or partial context?
Sample Answer
Direct answer
Adopt the W3C Trace Context standard (traceparent/tracestate headers) as the single canonical propagation format, translate legacy headers to it at the edge (gateways and service-mesh sidecars) during migration so every hop sees a standard header regardless of what the originating service still emits, and enforce adoption with automated propagation-continuity tests in CI rather than a one-time audit. A request that shows up with missing or partial context gets a freshly minted root trace ID at the first trusted boundary, tagged so it's visibly distinguishable from a properly-propagated trace during debugging.
Migration and enforcement approach
- Translate at the edge first, not the leaves. API gateways, load balancers, and service-mesh sidecars are a small, centrally-controlled set of chokepoints compared to 1,000 individual services. Teaching them to read a legacy header (e.g.
X-Trace-Id) and emit a standardtraceparentalongside it (marking origin intracestate) gets standard propagation working end-to-end immediately, without waiting on every service team. - Provide a thin, zero-dependency propagation library per language, not a full tracing SDK. Services only need the ability to read/attach context and forward it through HTTP, gRPC, and message-queue headers; that's a much smaller adoption ask than "instrument your whole service."
- Dual-write during the transition: once a service is updated, it reads both legacy and standard headers (preferring standard) and writes both, so downstream services that haven't migrated yet still get what they expect.
- Cut over legacy emission once adoption crosses a high-confidence threshold (e.g. 95%+), then remove the compatibility shim from the edge translators; keeping compatibility code indefinitely is itself a long-term maintenance and correctness liability.
Enforcement, not just adoption
- CI test that fails a build if outgoing requests from an instrumented service don't carry a valid
traceparent: propagation loss is caught at merge time, not in a production incident three weeks later. - A live metric,
propagation_loss_total, incremented whenever a service receives a request with no trace context on a path where the caller was known to be migrated; this turns "context got dropped somewhere" from an anecdote into an alertable, per-service signal during rollout.
Handling missing or partial context
- Fully missing (
traceparentabsent): the first trusted boundary (edge gateway or service-mesh ingress) mints a new 128-bit trace ID and markstracestatewithorigin=synthesizedso anyone debugging later immediately knows this trace didn't start where they'd expect. - Partial (trace ID present, span ID missing or malformed): create a new span with the given trace ID as parent context where possible, and record the same
partial=trueflag; don't silently drop the partial trace ID information, since even a broken parent link is more useful for correlation than starting fresh. - Sampling decisions: if a sampler hint is present in
tracestate, honor it; if absent, apply deterministic (hash-of-trace-ID) sampling at the edge so downstream services don't each make an independent, inconsistent sampling call for the same trace.
Worked example
Header overhead. A traceparent header (00-<32 hex trace id>-<16 hex parent id>-<2 hex flags>) is 2+1+32+1+16+1+2=55 bytes. Assume an average tracestate payload of 40 bytes, for 55+40=95 bytes of propagation overhead added per request.
At an assumed average of r=200 requests/sec per service across n=1,000 services:
R=n×r=1,000×200=200,000 req/s fleet-wide Bandwidth overhead=R×95 bytes=19,000,000 bytes/s=19 MB/s aggregateThat's a small, easily-budgeted fixed cost across the whole fleet, confirming the "minimal runtime overhead" requirement is satisfiable by the header format choice itself.
Migration cadence. With edge translation already giving end-to-end propagation on day one, the remaining work is migrating each service off legacy-only emission. At a rollout cadence of B=50 services/week (a process/scheduling parameter the team sets, driven by how many services can be safely canaried per week):
Weeks to full migration=⌈1,000/50⌉=20 weeks Weeks to 95% adoption=⌈950/50⌉=19 weeksThe compatibility shim at the edge stays in place through week 19-20 and is removed only after the 95% threshold and a grace period, which is what makes the cutover safe rather than a hard deadline that breaks the long tail of stragglers.
flowchart LR
Request[Incoming Request] --> Gateway[Edge Gateway]
Gateway -->|has traceparent| Propagate[Forward W3C Context]
Gateway -->|missing or partial| Mint[Mint Root ID + partial flag]
Propagate --> LegacySvc[Not-yet-migrated Service]
Mint --> LegacySvc
LegacySvc --> Shim[Compat Shim: legacy to W3C]
Shim --> Collector[OTel Collector]
Collector --> Backend[Trace Backend]
Trade-offs and pitfalls
- Edge-only translation gets end-to-end propagation working fast, but it means internal, service-to-service context (span-level parent/child relationships between two not-yet-migrated services) is still lossy until those specific services adopt the standard library; edge translation solves the trace-ID-continuity problem, not the full-fidelity-span-tree problem.
- Dual-writing both header formats during transition roughly doubles header overhead temporarily; that's an accepted, bounded cost (the 19 MB/s figure above would briefly be closer to double) in exchange for a safe rollout with no hard cutover date.
- Removing the compatibility shim too early, before the long tail of stragglers has actually migrated, silently breaks propagation for exactly the services least likely to have good test coverage; the 95% threshold plus a grace period exists specifically to avoid that failure mode.
- Marking synthesized root traces (
origin=synthesized) is easy to skip under time pressure but is what prevents a debugging engineer from wasting time trying to find a "missing" parent span that never existed.
Design the telemetry data model for a long-running batch job or data pipeline: job-level SLIs (throughput, success rate, lag), task-level metrics, and asset-level lineage. How would you use correlation IDs and idempotency so that retries and partial failures get attributed to the right job run instead of double-counted or lost?
Sample Answer
Direct answer
Use three correlation identifiers with distinct scopes: job_id for the logical run, task_instance_id for a specific task within it, and attempt_id for a specific retry of that task, and put them in the right telemetry signal for their cardinality. Metrics get labeled only by low-cardinality dimensions like task_name (a fixed, small set), while job_id/task_instance_id/attempt_id live on trace spans and structured logs, which are built to handle high-cardinality identifiers. A durable idempotency ledger keyed on job_id + task_name makes retries and partial failures attribute correctly instead of double-counting or getting lost.
Identifier design
job_id: one per logical run of the pipeline (a specific execution of "yesterday's ETL," not the pipeline definition itself).task_instance_id: one per task within that run, stable across retries of the same task.attempt_id: increments on each retry of atask_instance_id.- All three propagate together through logs, trace spans, and lineage events, so any of the three signals can be joined against the others for a given execution.
Why the label placement matters: cardinality
Cardinality here means the number of distinct label-value combinations a metric can produce; each distinct combination is a separate time series the metrics backend has to store and index. A metric labeled by something that's unique per job run (like task_instance_id) creates a brand-new time series on every single run, forever, which is exactly the failure mode metrics backends are not built for.
Metrics: aggregate counters/histograms keyed only by task_name (a fixed set, e.g. 50 distinct task names), plus a small number of other genuinely low-cardinality dimensions like environment or region. Never job_id, task_instance_id, or attempt_id as a metric label.
Traces: one root span per job_id, child spans per task attempt, with job_id, task_instance_id, attempt_id as span attributes (not labels that create new series). Trace backends are designed for exactly this kind of high-cardinality attribute.
Logs: structured JSON logs carrying the same identifiers, for full-detail debugging of a specific run.
Lineage: events keyed by asset_id, carrying job_id as a join key back to the run.
Idempotency and retry attribution
- A durable status store records terminal outcomes keyed by
job_id + task_name(notattempt_id), written via compare-and-swap. A retried attempt that races with an already-succeeded prior attempt loses the CAS and cannot double-apply its effect. - Job-level SLIs (success rate, throughput) are computed from terminal status only, not from every attempt. A task that fails twice and succeeds on the third attempt counts once, as a success, in the job-level SLI; the retry history is visible in traces/logs for debugging, but doesn't pollute the aggregate metric.
- Partial failures (some tasks in a job succeed, others don't) are recorded per-
asset_idin the lineage stream, so a downstream consumer can tell exactly which outputs are trustworthy even if the overall job is marked failed.
Worked example
Assume N=10,000 job runs/day, each with T=50 tasks.
If a metric were (incorrectly) labeled by task_instance_id:
Against a typical metrics-backend active-series budget (illustrative figure of 1,000,000 active series for a shared cluster), this single pipeline alone would consume roughly half the entire budget every single day, and it never stops growing since task_instance_id values never repeat.
Labeled correctly by task_name instead:
The correct label placement isn't a minor optimization, it's the difference between a metrics backend that stays healthy indefinitely and one that degrades daily as more job runs accumulate.
flowchart LR
JobRun[Job Run: job_id] --> Task[Task Attempt: task_name + attempt_id]
Task --> IdemStore[Idempotency Store: CAS by job_id + task_name]
Task --> Metrics[Metrics: labeled by task_name only]
Task --> Trace[Trace Span: job_id + task_instance_id attrs]
Task --> Lineage[Lineage Event: asset_id + job_id]
IdemStore --> Finalizer[Job Finalizer: terminal SLI]
Metrics --> Finalizer
Lineage --> Catalog[Data Catalog]
Trade-offs and pitfalls
- The most common mistake in batch-job telemetry is putting a per-run identifier directly on a metric label because it's convenient for a one-off debugging query; it's cheap the first time and catastrophic at scale, exactly because cardinality growth is invisible until the backend is already struggling.
- Attempt-level metrics (per-attempt counters) are still useful for detecting retry storms or flaky tasks, but they should be aggregated at
task_namegranularity (a counter incremented per attempt, not a new series per attempt), keeping the diagnostic value without the cardinality cost. - Idempotency keyed on
job_id + task_nameassumes tasks within a job have stable, unique names; a pipeline that dynamically generates task names (e.g. embedding a partition key in the name) reintroduces the same cardinality and idempotency problems this design was built to avoid, so dynamic task naming needs its own bounded-cardinality scheme. - Computing SLIs from terminal status only is correct for "did the job ultimately succeed," but it can mask a job that's technically succeeding while burning through an unhealthy number of retries; that's why attempt-count needs its own aggregated signal (task-level, not job-level) even though it isn't the primary SLI.
Compare three ways to deploy telemetry collection in Kubernetes: a DaemonSet agent running once per node, a sidecar container per pod, and a centralized collector per cluster. For each, weigh resource overhead, network topology, configuration management, and behavior during rolling updates, and explain when you'd pick each one.
Sample Answer
Direct Answer
DaemonSet agents are the default for anything needed uniformly across every node (host and container runtime metrics, general log collection): one process per node, flat overhead, and centrally managed config. Sidecars earn their much higher per-instance cost only when a specific pod needs isolated, per-app processing a shared node agent cannot provide. Centralized collectors minimize total resource overhead but add a network hop and a central point of configuration and, if not run with real replication, a central point of failure.
Structured Elaboration
| Dimension | DaemonSet agent | Sidecar per pod | Centralized collector |
|---|---|---|---|
| Resource overhead | One process per node, low total footprint | One process per pod, multiplies with pod count | Few replicas, lowest total footprint |
| Network topology | Local (loopback/host network) to the node | Local (loopback) to the pod | Cross-node hop to a shared service |
| Config management | Centralized via one DaemonSet spec | Per-app injection (webhook or shared ConfigMap), harder to change globally | Fully centralized, no app redeploy needed to change pipeline logic |
| Rolling-update behavior | Survives per-node pod churn independently of app pods | Tied to app pod lifecycle, upgrading the collector often means redeploying every app pod | Independent of both node and app churn, needs its own HA and PodDisruptionBudget |
| Best for | Uniform, node-wide signal (infra metrics, general logs) | Per-app isolation, custom pipelines, apps needing guaranteed local flush before termination | Heavy processing (tail sampling, enrichment) that benefits from fleet-wide visibility |
Topology comparison
flowchart LR
subgraph DS["DaemonSet Pattern"]
NODE["Node"] --> AGENTD["DaemonSet Agent"]
end
subgraph SC["Sidecar Pattern"]
POD["Pod"] --> SIDE["Sidecar Container"]
end
subgraph CC["Centralized Pattern"]
APPS["App Pods"] --> GATEWAY["Central Collector"]
end
AGENTD --> BACKEND[("Backend")]
SIDE --> BACKEND
GATEWAY --> BACKEND
Worked Example
Assume a 200-node cluster averaging 15 pods/node, so 3,000 pods total.
DaemonSet: one agent pod per node, each requesting 100m CPU / 128Mi memory (a stated sizing assumption):
200×100m=20,000m=20 vCPU,200×128Mi=25,600Mi=25 GiSidecar: one lean sidecar per pod, each requesting half the DaemonSet's per-instance footprint, 50m CPU / 64Mi memory (an assumed leaner-per-instance sizing, still multiplied by far more instances):
3,000×50m=150,000m=150 vCPU,3,000×64Mi=192,000Mi=187.5 GiThat is 7.5x the DaemonSet's CPU and memory footprint, for the same cluster:
20150=7.5,25187.5=7.5(the ratio matches exactly because per-instance overhead was assumed at half the DaemonSet's, times 15 pods/node, giving 15×0.5=7.5).
Centralized collector: 5 replicas, each provisioned heavier since it aggregates fleet-wide (2 vCPU / 4Gi each, a stated assumption):
5×2=10 vCPU,5×4Gi=20 GiHalf the DaemonSet's CPU and 1/15th the sidecar's, at the cost of a network hop and, with only 5 replicas, meaningfully more disruption if two of them go down at once than losing two of 200 DaemonSet pods.
Trade-offs and Pitfalls
For log collection specifically (folded in from the log-focused variant of this comparison), sidecar and DaemonSet differ in a way the metrics comparison above does not capture: a DaemonSet log shipper reads container log files from the node's filesystem, and a very short-lived pod (a fast-completing Job) can be garbage-collected and its logs rotated away before the node-level shipper gets to them. A sidecar, tied to the same pod lifecycle, can use a termination hook to flush its buffer before the pod actually exits, which is a real advantage for ephemeral workloads even though it costs far more in steady-state resource overhead.
The 7.5x resource multiplier for sidecars is not a fixed law, it is a direct consequence of pods-per-node (15 in this example). A cluster running fewer, larger pods per node (say 4 pods/node) would show a much smaller sidecar penalty (2x instead of 7.5x at the same per-instance assumption), so this trade-off should be recalculated against the actual cluster's pod density, not assumed to generalize.
Centralized collectors concentrate risk: with only 5 replicas instead of 200 independent DaemonSet pods, losing 2 replicas to a bad node or a bad deploy is a much bigger fraction of total capacity. Run centralized collectors with genuine multi-AZ spread and a PodDisruptionBudget, not just multiple replicas on the same failure domain.
Design a distributed tracing sampling system that guarantees every trace involving an error or a rare, high-severity condition is retained for analysis, while keeping total storage cost under a fixed budget. Walk through your buffering approach, what signals feed the sampling decision, and the trade-off between decision latency and correctness.
Sample Answer
Direct Answer
Use tail-based sampling with a short per-trace buffering window, so the sampling decision can see whether an error or rare condition appeared anywhere in the trace before deciding to keep or drop it. Split the policy in two: always retain traces carrying an error or rare-severity flag (unconditionally, outside the budget), then probabilistically sample the remaining normal traffic at whatever rate fits what budget is left. The buffer window is the dial between decision correctness (catching a late-arriving error span) and cost (memory held per in-flight trace, plus decision latency).
Structured Elaboration
Sampling pipeline
flowchart LR
SPANS["Incoming Spans"] --> BUF[("Per-Trace Buffer, N sec window")]
BUF --> DEC{"Trace complete or window expired"}
DEC -->|"error / rare signal"| KEEP["Always Retain"]
DEC -->|"normal"| SAMP["Budget-Aware Sampler"]
SAMP -->|"sampled in"| KEEP
SAMP -->|"sampled out"| DROP["Discard"]
KEEP --> STORE[("Trace Storage")]
Buffering approach
Spans for a given trace ID accumulate in a short-lived per-trace buffer as they arrive out of order from different services. The buffer closes and a decision is made either when the trace looks complete (a root span closes) or when a maximum window expires, whichever comes first, so a trace that never completes cleanly (a dropped span, a crashed service) does not hold the buffer open indefinitely.
Signals feeding the sampling decision
- Any span carrying an error status or exception.
- A latency signal: a span exceeding a defined threshold for its operation.
- A rare, high-severity condition flag emitted explicitly by the service (a specific error code, a business-critical operation type).
- For the remaining normal traffic, a running measure of budget consumed so far in the current period, feeding a dynamic sampling probability.
Decision and retention policy
- If any signal above fires: retain unconditionally, outside the budget calculation.
- Otherwise: sample at a probability computed to hit the remaining budget for the period (see worked example), using a consistent, deterministic hash of the trace ID so the decision is reproducible if re-evaluated.
Latency versus correctness trade-off
A longer buffer window catches more late-arriving error signals (higher correctness) but holds more traces in memory longer and delays the retain/drop decision (higher latency and cost). A shorter window decides faster and cheaper but risks finalizing a trace as "normal" just before its error span arrives from a slow downstream hop.
Worked Example
Assume a fleet-wide rate of 50,000 traces/sec, an average trace size of 5,000 bytes (spans, tags, and metadata combined, a stated design input), and a measured error rate of 0.5% of traces (a stated assumption feeding the calculation, not a claim about any specific system).
Total traces/day: 50,000×86,400=4.32×109.
Error traces/day (always kept): 0.005×4.32×109=2.16×107, costing 2.16×107×5,000 B≈108 GB/day.
Given a fixed storage budget of 500 GB/day, the remaining budget for non-error traces is 500−108=392 GB/day.
Non-error traces/day: 4.32×109−2.16×107=4,298,400,000.
Required sampling rate on non-error traffic to spend exactly the remaining budget:
rate=4,298,400,000×5,000 B392×109 B≈2.149×1013392×109≈1.82%So the system retains 100% of error traces and roughly 1.82% of normal traces, for a blended retention rate of about 500/21,600≈2.31% of all traces by volume (total unsampled data would be 4.32×109×5,000 B≈21,600 GB/day).
Buffer memory. Holding a 5-second decision window at the full incoming rate: 50,000×5,000 B×5 s=1.25×109 B≈1.25 GB of in-flight trace buffer needed per collector-tier aggregate, a concrete, provisionable memory figure rather than an open-ended "keep enough buffer" statement.
Trade-offs and Pitfalls
Head-based sampling (deciding at the very first span, before the rest of the trace exists) is cheaper and adds no buffering latency, but structurally cannot guarantee error retention: the decision happens before the error span, if any, has even occurred. Tail-based sampling is the only way to honor "always keep errors," at the cost of the buffer described above.
A fixed buffer window creates a silent correctness gap for the tail of the trace-duration distribution: if the window is 5 seconds but a small fraction of traces (the slowest, often the most interesting) take 8+ seconds end to end, those traces get finalized and possibly dropped before their late error span arrives. Mitigate by extending the window specifically for traces that have already crossed a latency threshold mid-flight, rather than using one fixed window for everything.
Dynamic budget-based sampling can create a feedback loop under a real incident: an incident produces more errors, which consumes more of the always-keep budget, which is fine, but if the incident also produces more overall traffic, the non-error sampling rate has to drop to compensate, right when operators most want visibility into the surrounding normal traffic for comparison. Consider reserving a small, fixed floor sampling rate for normal traffic that the budget calculation cannot squeeze to zero.
Unlock Full Question Bank
Get access to all 49 Observability and Monitoring Architecture interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.