Log Analysis and Diagnostic Data Gathering Questions
Extracting signal from existing logs and diagnostic output to find a root cause: parsing and querying log data, correlating traces and metrics during an investigation, and gathering the right diagnostic information (including asking clarifying questions) before drawing conclusions. Covers text-processing and query techniques for locating evidence in logs (structured log parsing, ElasticSearch/SQL-style log queries, log aggregation and retention trade-offs) and reconstructing a timeline from the data on hand. This is the analysis-of-existing-data skill used during troubleshooting and investigation across infrastructure and operations roles: distinct from monitoring and observability, which is about instrumenting a system so telemetry exists in the first place (see the observability topics for that), and distinct from SIEM-based security detection and formal digital-forensics practice (chain of custody, artifact/disk/memory analysis), which have their own dedicated coverage elsewhere in the catalog.
Explain common logging severity levels (debug, info, notice, warning, error, critical/crit, alert, emergency) and how they map to syslog numeric priorities / priority names. Discuss production strategies for controlling volume (rate-limiting, sampling) of debug-level logs without losing context needed for post-incident analysis.
Sample Answer
Direct answer
The common application-level severity levels, from least to most severe, are debug, info, notice, warning, error, critical, alert, and emergency; these map directly onto syslog's numeric priority scale (RFC 5424), where a lower number means more severe. In production, the challenge isn't picking a level per log line, it's controlling the volume of the noisiest levels (mainly debug) without losing the context you'd need during an actual incident.
Structured elaboration
| Level | Syslog number | When to use it |
|---|---|---|
| Emergency | 0 | System is unusable |
| Alert | 1 | Action must be taken immediately |
| Critical | 2 | Severe failure (a subsystem is down) |
| Error | 3 | A definite problem affecting functionality; actionable |
| Warning | 4 | Something may need attention soon; system still works |
| Notice | 5 | Normal but noteworthy event |
| Info | 6 | Normal operation (startup, config loaded, request handled) |
| Debug | 7 | Fine-grained diagnostic detail, developer-facing |
The number ordering is deliberately inverted from how severity intuitively reads: 0 is the worst outcome (system unusable), 7 is the most trivial (debug trace). This is why a journalctl -p warning style filter for "warning and above" actually means "priority number 4 or lower."
Controlling debug volume in production
- Sample rather than suppress entirely: keep 100% of
errorand above, but only log a small fraction (say 1%) ofdebug/infoevents, so you retain a representative slice of normal-operation detail instead of losing it completely. - Correlation IDs as a safety net for sampling: always attach a request or trace ID even to sampled-out events' surrounding context, so if a specific request later turns out to matter, you can request full-detail logging for that one flow rather than needing every debug line for every request.
- Runtime-adjustable verbosity: make log level a live-toggleable setting (a config flag, a feature flag) per service or per instance, so you can turn debug on temporarily for the specific component you're investigating instead of running at high verbosity everywhere, all the time.
- Rate limiting: cap how many times an identical message can be logged per interval; a single misbehaving code path that logs the same error thousands of times per second drowns out everything else in the same window.
Worked example
A payments service normally logs at info level (a few hundred lines a minute) but, during a debugging session, someone left debug enabled in production. Debug volume alone is now tens of thousands of lines a minute, filling the retention window in under an hour and pushing out the error-level lines from the actual incident being investigated three hours earlier before anyone could pull them. Sampling debug output at 1% instead of logging every line, combined with keeping 100% of error, would have kept the incident's own error lines intact for the full retention window while still preserving a usable slice of the debug detail.
Trade-offs & pitfalls
The most common production mistake is treating log level purely as a filter for what a human reads on a terminal, rather than as a volume/cost control. Leaving debug logging on everywhere "just in case" feels safe but actively works against incident response: it accelerates log rotation, increases the cost of every downstream aggregation query, and buries the handful of error/warning lines that actually matter in noise. The opposite mistake, running everything at error only, is just as damaging: when an incident does happen, there's no info/debug context around it to reconstruct what led up to the failure, which is exactly why sampling (some debug, always) tends to beat an all-or-nothing choice.
Given the following log line format: '2025-05-01T12:34:56Z level=error srv=payments request_id=abc123 user_id=42 msg="checkout failed" latency_ms=562', write a Python function that parses arbitrary lines conforming to this structure into a dictionary, handles missing keys gracefully, and converts numeric fields. Provide example input and expected output.
Sample Answer
Direct answer
Split the leading timestamp token off, then pull every key=value pair with a regex that also handles quoted values containing spaces (like msg="checkout failed"), and convert values that look numeric into int/float. Keys that never appear in a given line simply never get a dict entry, so callers should use .get(key, default) rather than assuming every key is always present.
Approach
import re
KV_RE = re.compile(r'(\w+)=("[^"]*"|\S+)')
def parse_log_line(line):
"""Parse a line like:
2025-05-01T12:34:56Z level=error srv=payments request_id=abc123 ...
into a dict. The leading token (no '=') is treated as the timestamp.
Numeric-looking values are converted to int/float. Missing keys simply
do not appear in the result (callers should use dict.get with a default).
"""
parts = line.strip().split(" ", 1)
if not parts:
return {}
result = {}
if "=" not in parts[0]:
result["ts"] = parts[0]
rest = parts[1] if len(parts) > 1 else ""
else:
rest = line.strip()
for key, raw_val in KV_RE.findall(rest):
val = raw_val.strip('"')
if re.fullmatch(r'-?\d+', val):
val = int(val)
elif re.fullmatch(r'-?\d+\.\d+', val):
val = float(val)
result[key] = val
return result
line1 = ('2025-05-01T12:34:56Z level=error srv=payments request_id=abc123 '
'user_id=42 msg="checkout failed" latency_ms=562')
line2 = '2025-05-01T12:35:10Z level=info srv=payments msg="health check ok"'
for line in (line1, line2):
parsed = parse_log_line(line)
print(parsed)
print(" user_id:", parsed.get("user_id", "MISSING"))
Worked example (executed output)
{'ts': '2025-05-01T12:34:56Z', 'level': 'error', 'srv': 'payments', 'request_id': 'abc123', 'user_id': 42, 'msg': 'checkout failed', 'latency_ms': 562}
user_id: 42
{'ts': '2025-05-01T12:35:10Z', 'level': 'info', 'srv': 'payments', 'msg': 'health check ok'}
user_id: MISSING
Key points
- The regex
(\w+)=("[^"]*"|\S+)matches a bare key, then either a double-quoted value (captured without its quotes) or a plain whitespace-delimited value; this is what correctly keeps"checkout failed"as one value instead of splitting on the space inside it. - Numeric conversion is deliberately conservative: only strings that are entirely digits (with an optional leading
-) becomeint, and only a simple-?\d+\.\d+shape becomesfloat. Everything else, includingrequest_id=abc123, stays a string, which avoids accidentally coercing an alphanumeric ID that happens to start with digits. - Missing keys are absent, not
None. The second example line has nouser_idat all, andparsed.get("user_id", "MISSING")shows that explicitly, which is the "handle missing keys gracefully" behavior the question asks for.
Complexity
O(n) time and O(k) space, where n is the line's length and k is the number of key=value pairs, since the regex makes a single pass and each match does constant-time work.
Edge cases
- A key that appears twice: the current implementation lets the later occurrence overwrite the earlier one, since it's just a dict assignment in a loop; if you needed to preserve repeats, you'd collect values into a list instead.
- A value that looks numeric but isn't meant to be, like a zip code
user_zip=02139: this would be silently converted to the integer2139, losing the leading zero. If exact string preservation matters for some fields, you'd need a field-name allowlist for numeric conversion rather than converting anything digit-shaped. - Malformed tokens (a stray
=with nothing after it, or unbalanced quotes) simply fail to match the regex and are skipped rather than raising, which keeps the parser from crashing on one bad line in a large file.
Trade-offs & pitfalls
The main trade-off is permissiveness versus strictness: this parser is intentionally lenient (skip what doesn't match, coerce what looks numeric) because production log parsing usually needs to tolerate slightly malformed input rather than halt on it. The cost is exactly the zip-code-style edge case above, where "looks numeric" and "is semantically numeric" diverge; a stricter, schema-aware parser would need to know in advance which fields are genuinely numeric.
Given a sample Apache access log line formatted without quotes:
127.0.0.1 - frank [10/Oct/2024:13:55:36 -0700] GET /index.html HTTP/1.1 200 2326
Write a single PCRE regular expression to extract the following named groups: client_ip, user, timestamp, method, path, protocol, status, bytes. Show how you'd run grep -P or awk to capture these fields and mention timestamp parsing caveats (timezones, format).
Sample Answer
Direct answer
Match each field positionally with a PCRE (Perl Compatible Regular Expression, the regex flavor used by grep -P, Perl, and Python's re module) pattern using named groups, one group per field. Named groups make the extraction self-documenting and let downstream code read match["client_ip"] instead of a numbered \1.
Approach
import re
PATTERN = re.compile(
r'^(?P<client_ip>\S+) \S+ (?P<user>\S+) '
r'\[(?P<timestamp>[^\]]+)\] '
r'(?P<method>[A-Z]+) (?P<path>\S+) (?P<protocol>[A-Z]+/\d\.\d) '
r'(?P<status>\d{3}) (?P<bytes>\d+|-)$'
)
line = '127.0.0.1 - frank [10/Oct/2024:13:55:36 -0700] GET /index.html HTTP/1.1 200 2326'
m = PATTERN.match(line)
if m:
for k, v in m.groupdict().items():
print(f"{k}: {v}")
else:
print("no match")
Worked example (executed output)
client_ip: 127.0.0.1
user: frank
timestamp: 10/Oct/2024:13:55:36 -0700
method: GET
path: /index.html
protocol: HTTP/1.1
status: 200
bytes: 2326
The same named-group syntax works directly with grep -P on the command line (grep -oP prints only the matched text), and the equivalent extraction with awk, splitting on whitespace since this log line has no embedded quoted fields, looks like:
LINE='127.0.0.1 - frank [10/Oct/2024:13:55:36 -0700] GET /index.html HTTP/1.1 200 2326'
echo "$LINE" | awk '{
ip=$1; user=$3;
ts=$4" "$5; gsub(/[\[\]]/,"",ts);
printf "client_ip=%s user=%s timestamp=%s method=%s path=%s protocol=%s status=%s bytes=%s\n", ip, user, ts, $6, $7, $8, $9, $10
}'
which prints client_ip=127.0.0.1 user=frank timestamp=10/Oct/2024:13:55:36 -0700 method=GET path=/index.html protocol=HTTP/1.1 status=200 bytes=2326, matching the Python extraction field for field. awk works here specifically because this log line has no spaces inside any field; a format with a quoted user-agent field (spaces included) would break plain whitespace splitting and need the regex approach instead.
Trade-offs & pitfalls
Timestamp parsing caveats: the bracketed timestamp 10/Oct/2024:13:55:36 -0700 is Apache's own format, not ISO 8601, so a generic date parser will not accept it as-is; you need a format string like %d/%b/%Y:%H:%M:%S %z (Python strptime) that matches it exactly. The trailing -0700 is a fixed UTC offset, not a named timezone, and does not account for daylight saving time changes on its own; if you're aggregating log lines from hosts in different timezones, convert every timestamp to UTC immediately after parsing rather than comparing the raw offset strings.
A second pitfall: this pattern assumes the log line has no quoted fields (no referrer, no user-agent), which is why it splits cleanly on whitespace. The moment a field can contain a space (a real Apache "combined" format log includes a quoted user-agent), whitespace-based extraction breaks and you need to match the quotes explicitly, as covered in the ELK grok-pattern version of this same problem.
Write a Python 3 script that reads a newline-delimited log file where each line is a JSON object with keys: "timestamp" (ISO 8601), "service", "level", "message". The script should output per-minute error counts (level == "ERROR") for a given service over the last 60 minutes, printing lines like: 2025-03-12 14:05 3. The log may be out-of-order and can be large (~10GB): prioritize streaming and bounded memory.
Sample Answer
Direct answer
Stream the file line by line and keep only a small dict of per-minute counters, never the raw lines, so memory stays bounded regardless of file size. Because the file can be out of order, the true 60-minute window boundary (which minute is "now") isn't known until you've seen the latest timestamp, so prune the counters dict opportunistically as later, larger timestamps arrive, rather than needing a full second pass over the file.
Approach
import json
from datetime import datetime, timedelta, timezone
WINDOW_MINUTES = 60
def _parse_ts(raw):
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def per_minute_error_counts(path, service):
"""Stream a large NDJSON log file and return per-minute ERROR counts
for `service` over the last WINDOW_MINUTES relative to the latest
timestamp seen, as a dense list of (minute, count) tuples (one entry
per minute, zero-filled).
The file may be out of order. Rather than buffering every distinct
minute seen across the whole file, the counts dict is pruned WHILE
streaming: every time a new maximum timestamp arrives, anything
older than (new_max - WINDOW_MINUTES) is dropped. Memory therefore
stays close to O(WINDOW_MINUTES) even over a ~10GB file, at the cost
of one bookkeeping check per line rather than a second pass.
"""
counts = {}
newest = None
with open(path, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
if rec.get("service") != service:
continue
raw_ts = rec.get("timestamp")
if not raw_ts:
continue
try:
ts = _parse_ts(raw_ts)
except ValueError:
continue
if newest is None or ts > newest:
newest = ts
cutoff = newest - timedelta(minutes=WINDOW_MINUTES - 1)
for bucket in [b for b in counts if b < cutoff.replace(second=0, microsecond=0)]:
del counts[bucket]
if rec.get("level") == "ERROR":
bucket = ts.replace(second=0, microsecond=0)
counts[bucket] = counts.get(bucket, 0) + 1
if newest is None:
return []
window_start = (newest - timedelta(minutes=WINDOW_MINUTES - 1)).replace(second=0, microsecond=0)
result = []
cur = window_start
end = newest.replace(second=0, microsecond=0)
while cur <= end:
result.append((cur.strftime("%Y-%m-%d %H:%M"), counts.get(cur, 0)))
cur += timedelta(minutes=1)
return result
if __name__ == "__main__":
import os
path = "/tmp/loganalysis_b1_s18.ndjson"
lines = [
{"timestamp": "2025-03-12T14:05:10Z", "service": "checkout", "level": "ERROR", "message": "timeout"},
{"timestamp": "2025-03-12T13:00:00Z", "service": "checkout", "level": "ERROR", "message": "too old, outside 60min window"},
{"timestamp": "2025-03-12T14:05:40Z", "service": "checkout", "level": "ERROR", "message": "timeout"},
{"timestamp": "2025-03-12T14:59:59Z", "service": "checkout", "level": "ERROR", "message": "latest event, defines the window end"},
{"timestamp": "2025-03-12T14:05:20Z", "service": "checkout", "level": "INFO", "message": "not an error"},
{"timestamp": "2025-03-12T14:07:00Z", "service": "checkout", "level": "ERROR", "message": "timeout"},
{"timestamp": "2025-03-12T14:07:05Z", "service": "auth", "level": "ERROR", "message": "different service"},
]
with open(path, "w") as f:
for rec in lines:
f.write(json.dumps(rec) + "\n")
for bucket, cnt in per_minute_error_counts(path, "checkout"):
if cnt:
print(f"{bucket} {cnt}")
os.remove(path)
Worked example (executed output)
2025-03-12 14:05 2
2025-03-12 14:07 1
2025-03-12 14:59 1
The synthetic input above is deliberately out of order and mixes services and levels: a 13:00 entry (an hour before the latest timestamp, correctly excluded from the 60-minute window), an INFO-level entry at 14:05 (correctly excluded, not an ERROR), and an entry from a different service, auth (correctly excluded). The three surviving lines match exactly what the question's example output format specifies: YYYY-MM-DD HH:MM count.
Key points
- Memory stays close to
O(WINDOW_MINUTES), notO(distinct minutes in the whole file): every time a new maximum timestamp arrives, the code immediately drops any bucket older thannew_max - 60 minutes, rather than waiting until the end of the file to filter. Over a ~10 GB file that could span far more than an hour of wall-clock time, this keeps the counters dict small throughout the scan instead of growing with every minute the file happens to contain. - The underlying function returns a dense per-minute series (one entry per minute of the 60-minute window, zero-filled where there were no errors), which is more useful than a sparse series if you intend to plot it; the driver code above only prints non-zero minutes to match the requested output format, but the zero-filled minutes are still available from the function's return value.
- Out-of-order tolerance comes from tracking the running maximum timestamp seen so far, not from assuming the file's line order matches timestamp order at all.
Complexity
Time is O(n) for n lines in the file, one pass, no sorting. Space is bounded by the number of distinct minute buckets retained at any point, which the opportunistic pruning keeps close to the 60-minute window rather than proportional to the file's total time span.
Edge cases
- No matching lines at all (wrong service, or no ERROR-level entries): the function returns an empty list rather than guessing a window relative to wall-clock "now," since there's no timestamp evidence in the file to anchor a window to.
- All events in the same single minute: the loop still correctly walks the full 60-minute range and reports that one minute's count alongside 59 zero counts in the dense series.
- A timestamp that fails to parse: skipped rather than raised, so one malformed line doesn't abort processing of a 10 GB file.
Trade-offs & pitfalls
The main design decision worth defending is anchoring "last 60 minutes" to the latest timestamp actually seen in the file, rather than to wall-clock now(). For a file being analyzed after the fact (which is the realistic scenario for "you're handed a log file and asked to find the error pattern"), the data's own latest timestamp is the meaningful reference point; anchoring to now() instead would silently produce an empty or wrong window whenever the file isn't from the current moment, which is the common case during an investigation. The trade-off of opportunistic pruning versus a simpler two-pass approach (scan once for the max timestamp, then scan again to aggregate) is one pass of I/O saved at the cost of slightly more bookkeeping per line; for a 10 GB file, halving the required I/O is worth that complexity.
Describe a small utility or automation (pseudocode or high-level steps) you would implement in Python to scan metrics or logs and identify the top services with rising error rates over a sliding 24-hour window. State inputs, outputs, aggregation method, and threshold logic.
Sample Answer
Direct answer
Pull per-service error and request counts for the last 24 hours and the 24 hours before that, compute each service's error rate (errors divided by requests) in both windows, and rank services by how much that rate increased, with a minimum absolute-error floor so a service going from 1 error to 2 doesn't dominate the list.
Structured elaboration
- Inputs: two time-bounded queries against your metrics or log backend (Prometheus, an ELK-style log store, or a data warehouse table):
current_windowcovering[now - 24h, now]andprevious_windowcovering[now - 48h, now - 24h], each returning(service, error_count, request_count)rows. - Aggregation method: sum errors and requests per service within each window (this is a single
GROUP BY servicein whichever backend you're querying), then computeerror_rate = errors / requestsfor each service in each window. - Threshold logic: flag a service as "rising" only if both hold: (1) the percentage increase in error rate versus the previous window exceeds a chosen threshold (e.g. 50%), and (2) the current window has at least some minimum absolute error count (e.g. 20). The second condition matters because a rate can double from a statistically meaningless base (1 error to 2) without being operationally significant; the floor filters that noise out.
- Output: a ranked list of
(service, percent_increase, current_rate, current_error_count), truncated to the top N (e.g. top 3), suitable for a Slack message or a dashboard panel.
Worked example
def find_rising_services(current, previous, top_n=3, pct_threshold=50.0, min_errors=20):
results = []
for service in set(current) | set(previous):
cur = current.get(service, {"errors": 0, "requests": 1})
prev = previous.get(service, {"errors": 0, "requests": 1})
rate_cur = cur["errors"] / (cur["requests"] or 1)
rate_prev = prev["errors"] / (prev["requests"] or 1)
pct_increase = ((rate_cur - rate_prev) / rate_prev * 100 if rate_prev > 0
else 100.0 if rate_cur > 0 else 0.0)
if cur["errors"] >= min_errors and pct_increase >= pct_threshold:
results.append((service, pct_increase, rate_cur, cur["errors"]))
results.sort(key=lambda r: r[1], reverse=True)
return results[:top_n]
Run against pinned example data for four services (current vs. previous 24h window):
current_window = {
"checkout": {"errors": 480, "requests": 40000},
"search": {"errors": 55, "requests": 50000},
"auth": {"errors": 12, "requests": 30000},
"recommend": {"errors": 300, "requests": 20000},
}
previous_window = {
"checkout": {"errors": 200, "requests": 39000},
"search": {"errors": 50, "requests": 49000},
"auth": {"errors": 10, "requests": 29500},
"recommend": {"errors": 40, "requests": 19800},
}
for service, pct, rate, errs in find_rising_services(current_window, previous_window):
print(f"{service:<10} +{pct:6.1f}% current rate={rate:.4%} errors={errs}")
Output:
recommend + 642.5% current rate=1.5000% errors=300
checkout + 134.0% current rate=1.2000% errors=480
search (7.8% increase) and auth (18% increase, and only 12 errors, below the floor) are correctly excluded: their rate barely moved and/or their volume is too low to act on.
Trade-offs & pitfalls
- Comparing only two adjacent 24-hour windows is sensitive to day-of-week effects (a Monday will look "worse" than a quiet Sunday even with no real regression); a production version would compare against the same weekday a week prior, or a rolling baseline, instead.
- A hard percentage threshold treats a jump from 0.001% to 0.002% the same as 10% to 20% unless the minimum-error floor is tuned well; the floor is doing real work here, not just cosmetic filtering.
- This is a scan-and-report utility, not a real-time alert; running it every few minutes as a scheduled job is usually enough, since "rising over 24h" is inherently a slow-moving signal, not something that needs sub-second reaction.
Unlock Full Question Bank
Get access to all 12 Log Analysis and Diagnostic Data Gathering interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.