Monitoring, Logging, and Observability Questions
Understanding running systems through their signals. Covers metrics, logs, and traces, instrumentation, dashboards, alerting design, and log analysis and correlation for debugging production. Emphasizes designing observability so problems are detectable and diagnosable before users are affected.
In a long-lived system, how do you evolve a structured logging or metrics schema over time, for example adding a new field or changing what a field means, without breaking dashboards, alerts, and tooling that depend on the old schema?
Sample Answer
Direct answer
Default to additive-only changes (new optional fields with sane defaults), never silently repurpose an existing field's name or meaning, and when the meaning genuinely has to change, introduce it as a new versioned field and dual-emit both the old and new during a defined deprecation window so every consumer (dashboards, alerts, downstream jobs) has time to migrate before the old one disappears.
Structured elaboration
Additive changes are the default and the cheap case
Adding a brand-new field with a sensible default (or simply absent, if consumers already tolerate unknown fields) is safe: existing dashboards and alerts that don't reference it are unaffected, and new tooling can start using it immediately. Most schema evolution should fit this case; if it doesn't, that's a signal the change is more than "add a field."
Never repurpose a field in place
Changing what an existing field means (e.g., a latency field that used to be measured in milliseconds and is now measured in microseconds, keeping the same name) is the most dangerous kind of change, because it fails silently: old dashboards keep running the same query and now show numbers that are wrong by a constant factor, with no error to alert anyone. A rename or unit change should always get a new field name (latency_ms retired in favor of latency_us, both emitted for a transition period), never an in-place redefinition.
Version the schema explicitly
Tag every emitted record with a schema_version. Consumers that need to branch on shape (a downstream parser, a strict dashboard query) can check the version rather than guessing from field presence. This also gives you a clean place to document exactly which version introduced which change.
Deprecation as a process, not an event
- Announce the field's replacement and the planned sunset date.
- Dual-emit: write both the old and new field for a fixed window.
- Track actual usage of the old field (query logs, dashboard/alert definitions referencing it) to confirm consumers have migrated, not just assume they have.
- Only stop emitting the old field once usage has genuinely dropped to zero (or the sunset date passes and remaining consumers have been explicitly notified they'll break).
Testing the transition
Contract tests (automated checks that a producer's output still satisfies what a known consumer expects) and shadow validation (running the old and new emission side by side and diffing the derived metrics they produce) catch the case where the "safe" additive change turns out to interact badly with an existing aggregation, before it reaches production dashboards.
Worked example
A service currently emits {"latency": 245, ...} where latency is milliseconds, and the team wants to switch to microsecond precision.
Wrong approach (in-place redefinition): change the emitter to write {"latency": 245000, ...} under the same field name. A dashboard panel computing avg(latency) over the last hour now silently reports a number 1000x larger with zero errors or warnings; anyone glancing at the dashboard sees "avg latency: 245000ms" and either panics or, worse, doesn't notice because the panel has no sanity bound configured.
Correct approach: add latency_us alongside the existing latency field, dual-emit both for a stated transition window (e.g., until every dashboard query referencing latency has been rewritten to use latency_us, confirmed by grepping the dashboard/alert config repository for the old field name), then drop latency only after that grep returns zero references.
The key diagnostic in this example: the failure mode is not "the pipeline throws an error," it's "the pipeline keeps running and produces a wrong number that looks plausible." That's why additive-with-a-new-name is the default, not an optional extra step.
| Strategy | Backward compat risk | Consumer effort required | When to use |
|---|---|---|---|
| Additive field, new name | None | None (opt-in) | Default choice for any new signal or unit/meaning change |
| Field deprecation (dual-emit then drop) | Low, if the window is long enough and usage is tracked | Must update queries before sunset | Retiring a field that's being replaced |
| In-place semantic change (same name, new meaning) | High: silent, no error | None until someone notices wrong numbers | Avoid; only defensible for a field with zero known consumers |
Trade-offs & pitfalls
- Dual-emitting indefinitely accumulates cost and confusion; every deprecation needs an explicit sunset date, not an open-ended "eventually."
- Tracking actual field usage (rather than assuming consumers migrated because you announced it) is the step most teams skip, and it's exactly the step that prevents a surprise outage when the old field is finally dropped.
- Additive changes still need CI-enforced schema compatibility checks (backward/forward compatibility validation), because "just add a field" can still break a strict consumer that rejects unknown fields.
- A silent semantic change is strictly worse than a loud break: a query that errors gets noticed and fixed; a query that keeps returning a plausible-looking wrong number can go unnoticed for months.
What's the difference between an SLI, an SLO, and an SLA? Walk through how you'd define each one concretely for a service you've worked on, including how you'd measure the indicator and what time window you'd use.
Sample Answer
Direct answer
An SLI is the measured signal (e.g. the percentage of requests that were fast and successful), an SLO is the internal target for that signal over a time window (e.g. 99.9% of requests succeed under 300ms over a rolling 30 days), and an SLA is the external, often contractual promise built on top of an SLO, usually with margin, and real consequences (service credits, penalties) if it's breached. In short: the SLI measures, the SLO targets, the SLA promises with a business wrapper attached.
Defining each one concretely, for an e-commerce checkout service
| Term | Definition here | Concrete value |
|---|---|---|
| SLI | Percentage of checkout requests that return successfully (2xx) within 300ms | Measured every minute from load-balancer access logs |
| SLO | Internal reliability target for that SLI | 99.9% of checkout requests succeed under 300ms, measured over a rolling 30-day window |
| SLA | External commitment to customers, usually looser than the SLO to leave margin | 99.5% monthly checkout availability, with service credits below that threshold |
Explained for a non-technical stakeholder: the SLO is the bar the engineering team holds itself to internally so problems get caught and fixed before they become customer-visible; the SLA is the, usually more forgiving, bar the business is willing to be held to externally, on paper, with money attached if it's missed. The gap between the two is deliberate headroom, not sloppiness.
Worked example: computing the error budget
The error budget is the amount of allowed failure baked into the SLO. It's what lets a team ship changes at all instead of freezing forever.
error budget=(1−SLO target)For the 99.9% SLO above:
error budget=1−0.999=0.001=0.1%Over the 30-day measurement window (30 days equals 2,592,000 seconds):
0.001×2,592,000s=2,592s≈43.2 minutesSo this service is allowed about 43 minutes of budget-consuming failure (however that failure is defined: downtime, over-300ms responses, and so on) across a 30-day window before the SLO itself is breached. If checkout handles, say, 1,000,000 requests over that same window, the equivalent request-based budget is:
0.001×1,000,000=1,000 requests allowed to violate the SLIBoth framings, time-based and request-based, describe the same budget; which one is more useful depends on whether the SLI is availability-style (was the service up) or ratio-style (what fraction of requests succeeded).
Trade-offs and pitfalls
- Confusing SLO and SLA in conversation causes real problems: teams sometimes design their alerting and release-gating around the SLA (the looser, contractual number) instead of the SLO (the tighter, internal number), which means by the time anyone notices, the team is already close to breaching the external promise with no margin left to react.
- An SLO with no error-budget policy attached is just a number on a dashboard; the value comes from what happens when the budget is nearly exhausted (freeze risky launches, redirect engineering time to reliability work), not from the target itself.
- Picking an SLI that doesn't reflect real user experience (e.g. "server process is running" instead of "requests succeed within an acceptable time") gives a green dashboard while users are still unhappy. The SLI has to be as close to what the user actually experiences as the team can measure.
What are the three pillars of observability? For each one, explain what kind of question it's best at answering, one blind spot it has on its own, and a concrete example of a production issue it would help you catch.
Sample Answer
Direct answer
Observability rests on three complementary signal types: metrics, logs, and traces. Metrics tell you something is wrong and roughly how bad; logs tell you what specifically happened in a given event; traces tell you where in a multi-service request the time or failure occurred. None of the three alone gives a complete picture: a strong incident response usually starts with one pillar to detect and scope the problem, then pivots to another to find root cause.
The three pillars
| Pillar | Best at answering | Blind spot alone | Production issue it would catch |
|---|---|---|---|
| Metrics | "Is something wrong right now, and how widespread?" (aggregated time series: rates, latencies, saturation) | No per-request context, can't tell you which specific request or user was affected | A slow memory leak: heap usage climbing steadily over days trips a capacity alert before an out-of-memory crash |
| Logs | "What exactly happened for this one request or event?" (discrete, timestamped records) | Expensive to query in aggregate at scale; no built-in sense of "normal," so you need to already suspect something to search for it | A payment failing with a specific exception, e.g. a null card-token field surfaced in the stack trace, that a dashboard would only show as "errors up" |
| Traces | "Where in the call chain did the time or failure happen?" (request-scoped, spans across services) | Usually sampled, so rare failures can be missed entirely; requires instrumentation and consistent context propagation to be useful | A checkout endpoint's p99 latency doubles; a trace shows 900ms of the 1000ms total sitting in a single downstream inventory-service span, isolating exactly which hop got slow |
Instrumentation example, one flow
For a checkout endpoint, an on-call engineer might instrument it like this: a checkout_requests_total counter metric with labels {status, payment_provider}, plus a checkout_latency_seconds histogram with the same labels for percentiles; a structured log line at the point of failure with fields {request_id, trace_id, error_type, payment_provider}; and a trace with spans named checkout.validate, checkout.charge, checkout.persist, each carrying the same trace_id that appears in the log line. The shared trace_id and request_id are what let you jump from "the metric moved" to "here is the specific failing request" to "here is the exact log line explaining why."
Trade-offs and pitfalls
- Treating one pillar as sufficient is the most common mistake: teams that only have logs end up searching blind during an incident because they have no aggregated signal telling them where to look first; teams that only have metrics can detect a problem but can't explain it.
- High-cardinality labels (like an unbounded user_id on a metric) turn cheap metrics into an expensive, slow-to-query mess; that data belongs in logs or traces instead.
- Trace sampling is a real trade-off: full sampling captures every rare failure but is expensive at scale; low sampling rates are cheap but can miss the exact failing request you need. Tail-based sampling (keep traces for slow or error requests) is a common middle ground.
- Retention windows differ by pillar in practice (metrics are cheap to keep for months, verbose logs and full traces are usually much more expensive to retain), which shapes how far back a postmortem can actually look.
What's the difference between structured and unstructured logging? Also, walk through when you'd log at DEBUG versus INFO versus WARN versus ERROR, and how that choice affects an on-call engineer during an incident.
Sample Answer
Direct answer
Unstructured logging is free-form text intended for a human to read line by line; structured logging emits each entry as a consistent, machine-parsable record (typically JSON) with named fields, so a log-query tool can filter, aggregate, and correlate across millions of lines the way you'd query a database. Log levels (DEBUG, INFO, WARN, ERROR) are an orthogonal concept from structure: they control which entries you even keep or surface, so during an incident an on-call engineer isn't wading through routine noise to find the handful of lines that actually explain what broke.
Structured versus unstructured
| Unstructured | Structured | |
|---|---|---|
| Format | Free text, e.g. 2026-07-17 ERROR payment failed for user 123: timeout | Consistent fields, e.g. {"level":"ERROR","service":"payments","user_id":"123","error_type":"timeout"} |
| Querying | Regex/grep, brittle if the message wording ever changes | Filter and aggregate directly on fields (status_code >= 500), stable across wording changes |
| Correlation | Hard to reliably join with traces or other services | A shared trace_id/request_id field lets you pivot straight from a metric spike to the exact request's log lines |
| Best for | Quick local debugging, human-only reading | Production systems at any real scale, automated alerting on log content |
When to log at each level, and why it matters during an incident
| Level | Use for | Effect on an on-call engineer during an incident |
|---|---|---|
| DEBUG | Fine-grained internal state, useful only when actively investigating | Should normally be off in production (or sampled), since it's high-volume noise; if it's flooding the log stream, it drowns out the ERROR line the engineer actually needs |
| INFO | Normal, expected events: a request completed, a job started | Confirms the system is doing what it should; useful for confirming a fix worked, not for finding the problem itself |
| WARN | Something unexpected happened but the system recovered or degraded gracefully (a retry succeeded, a fallback kicked in) | Early signal: a spike in WARN volume right before an incident often shows the system trying to compensate before it actually failed |
| ERROR | An operation failed and did not recover on its own | This is what the on-call engineer searches for first; ERROR entries should carry enough context (request_id, error_type, relevant IDs) to explain what failed without needing to reproduce it |
Worked example: a structured log line for a failed request
{
"timestamp": "2026-07-17T15:04:05.123Z",
"level": "ERROR",
"service": "payments-api",
"request_id": "req-8f21",
"trace_id": "trace-a93c",
"status_code": 502,
"duration_ms": 247,
"error_type": "UpstreamTimeout",
"message": "upstream charge provider timed out"
}
During an incident, an on-call engineer can now do something like find all ERROR entries with error_type: UpstreamTimeout in the last 15 minutes, grouped by service, instead of searching for the word "timeout" across every service's free-text logs and hoping the wording matches. The shared trace_id also lets them jump directly from this log line to the distributed trace for the same request.
Trade-offs and pitfalls
- Logging everything at INFO "just in case" defeats the purpose of levels: if INFO volume is as high as DEBUG would be, the on-call engineer is back to searching through noise. Levels only help if they're used with discipline.
- Structured logging without a shared, enforced schema across services becomes almost as unqueryable as unstructured text, just in JSON clothing; a
status_codefield that's a string in one service and an integer in another breaks cross-service queries. - Sensitive fields (user identifiers, tokens, payment details) need to be redacted or hashed at the point of logging, not cleaned up later; once something sensitive is in a log aggregator, deleting it retroactively is unreliable.
- DEBUG-level logging left on in production is a common, avoidable cost problem: log ingestion is usually billed by volume, and DEBUG noise can dominate that cost without adding proportional value.
A service, or a small fleet of them, has grown with inconsistent, mostly unstructured logging: some free text, some ad hoc key-value pairs, no shared schema. How would you design a structured logging approach for it? Cover what a log entry should capture, how you'd keep verbosity manageable on high-traffic endpoints, and how you'd roll the change out without breaking existing tooling or dashboards.
Sample Answer
Design a fixed schema (not a free-for-all), enforce it through a shared logging library rather than convention, control verbosity by sampling low-value log levels on hot paths while always keeping errors, and roll it out by dual-emitting the old and new formats side by side until every dashboard and script that greps the old format has migrated.
Framework
What a log entry should capture. At minimum, every structured log line needs:
timestamp(ISO 8601, UTC),level,service,envcorrelation_id/trace_id(andspan_idif tracing is wired up), so a log line can be tied back to the request it came frommessage(still free text, but now one field among many, not the whole line)- Context relevant to what's being logged:
route,status,duration_msfor a request-handling log;error.type/error.messagefor a failure
Sensitive fields (user identifiers, emails, anything PII) should never be logged raw. Either omit them, or hash/redact them at the point of emission, enforced by the shared logging library so it isn't up to each call site to remember.
Keep field values bounded, not just field names fixed. A stable schema still has a cardinality trap: a field like route should be a small set of route templates (/orders/{id}), not the raw URL with the real ID interpolated in, and free-text fields like stack traces should be capped in length rather than allowed to grow unbounded. Uncontrolled cardinality is what makes a downstream index expensive, so the schema review has to look at value shape, not just field names.
Why this pays off beyond "logs are searchable now." Once every service emits the same fixed fields, three concrete things get easier that were hard with free-text logs: alert rules can key off a stable field (error.type = "TimeoutError") instead of a regex over a message whose wording changes every release; a post-incident timeline can be reconstructed by filtering and sorting on correlation_id and timestamp across services instead of manually eyeballing each service's raw output in turn; and a legacy service that's risky to touch can be instrumented with this schema before any behavioral refactor, giving you a baseline of real production behavior (error rates, latency, call patterns) to diff the refactor against, turning "did the refactor change behavior" from a guess into something you can actually check.
Verbosity on high-traffic endpoints. Logs and metrics are not interchangeable: a hot endpoint doesn't need a log line per request just to know it's up, that's what a request-count metric is for. Reserve full logging for what metrics can't tell you:
- Always log ERROR and WARN at 100%, since those are rare and high-value.
- Sample INFO-level access logs on high-traffic endpoints (a small, fixed percentage), and keep the sample rate configurable per endpoint rather than global.
- Keep DEBUG out of production by default, gated behind a feature flag or short-lived toggle for active investigations.
Rollout without breaking existing tooling. The riskiest part of this kind of change is not the schema, it's cutting over a service whose logs some existing dashboard or on-call script already greps. A safe sequence:
- Introduce the shared logging library and have it emit the new structured fields alongside the existing free-text message, so old tooling that parses the raw message keeps working unchanged.
- Stand up new dashboards/queries against the structured fields in parallel with the old ones, and validate they agree.
- Once consumers (dashboards, alert rules, on-call runbooks) have migrated to the structured fields, deprecate the old free-text-only path.
- Roll out service by service, starting with a low-traffic one, not as a single cutover across the whole fleet.
flowchart LR
A[Service code] --> B[Shared logging library]
B --> C{Migration phase}
C -->|during rollout| D["Emit: old free-text message + new structured fields"]
C -->|after rollout| E[Emit: structured fields only]
D --> F[Log shipper]
E --> F
F --> G["Old dashboards (parse message field)"]
F --> H["New dashboards (query structured fields)"]
Worked example
Take one high-traffic endpoint doing 5,000 requests/sec. Suppose the team sets a per-endpoint logging budget of 200 INFO-level access-log events/sec for it, to keep total ingestion cost bounded. The required sample rate is:
sample rate=5,000 req/sec200 events/sec=0.04=4%Meanwhile, if this endpoint's baseline error rate is 0.3%, that's 0.003×5,000=15 error events/sec, which stays comfortably inside the budget even at 100% logging, so ERROR/WARN can stay unsampled while INFO gets sampled down to 4%. This is the concrete reasoning a strong candidate walks through: log level and sample rate aren't picked in the abstract, they come out of (a) an ingestion budget you've set and (b) the request/error volume you actually have.
Trade-offs and pitfalls
- Sampling INFO logs means a specific request that had no error and wasn't in the sampled 4% has no log trail. That's an acceptable trade for routine traffic, but pair it with always-log-on-error and, ideally, trace-based sampling that always keeps traces for anything flagged interesting (slow, retried, or touching a specific customer), so the rare interesting case isn't lost to the sample.
- A schema that isn't enforced by a shared library drifts fast: one team adds
userId, another addsuser_id, and six months later nothing joins cleanly. Put schema validation in the library, not in a style guide. - Rolling out too fast (cutting the whole fleet to the new format in one release) is the single biggest risk here: any on-call script, saved dashboard, or SIEM rule (a Security Information and Event Management rule: a saved detection query that watches log data for suspicious patterns) that still parses the old free-text line breaks silently until someone notices during an incident, which is the worst possible time to notice. The dual-emit window exists specifically to avoid that.
- Over-redacting can be its own failure mode: hashing or dropping a field that turns out to be needed for debugging (say, an order ID that isn't actually PII) makes incidents harder to resolve. Decide field by field, don't blanket-redact everything that looks user-related.
That is every published Monitoring, Logging, and Observability question for Data Engineer so far. Browse the other topics in this category, or practice this one interactively.