Structured logging exists so that log lines are data, not prose: one JSON object per line, parsed by the aggregator rather than by a regex someone wrote at 3am. Aggregate a batch of such lines into the numbers a dashboard actually shows.
A well-behaved line looks like:
{"ts": "2026-08-17T10:00:01Z", "level": "INFO", "status": 200, "path": "/predict", "latency_ms": 12.5}
Real log streams are never that clean — a library writes a plain-text line, a crash truncates a record. Log hygiene means counting those separately instead of letting them silently vanish or crash the parser.
Rules:
Dict key order in the output must be deterministic, so by_class is built with its keys sorted.
Implement:
def analyze_logs(lines):
Return a dict with keys "total", "malformed", "by_class", "error_rate", "top_error_path", in that order.
lines = ['{"status": 200, "path": "/predict"}',
'{"status": 500, "path": "/predict"}',
'oops not json',
'{"status": 404, "path": "/v1/missing"}']
print(analyze_logs(lines))
Output:
{'total': 3, 'malformed': 1, 'by_class': {'2xx': 1, '4xx': 1, '5xx': 1}, 'error_rate': 0.3333, 'top_error_path': '/predict'}
Three lines parse; the plain-text line is counted as malformed rather than dropped. One of the three valid lines is a 5xx, so the error rate is 0.3333.
lines = ['{"status": 200, "path": "/predict"}', '{"status": 500, "path": "/predict"}', 'oops not json', '{"status": 404, "path": "/v1/missing"}']
print(analyze_logs(lines)){'total': 3, 'malformed': 1, 'by_class': {'2xx': 1, '4xx': 1, '5xx': 1}, 'error_rate': 0.3333, 'top_error_path': '/predict'}oops not json fails to parse, so it is counted as malformed and excluded from total. The three valid lines bucket to 2xx, 5xx and 4xx by integer-dividing the status by 100. Only the 500 counts toward the error rate: 1 / 3 = 0.3333, and its path is the only 5xx path, so it is the top offender.
"status" key; everything else counts as malformedby_class keys are "1xx".."5xx", present only when observed, and must be inserted in sorted ordererror_rate counts 5xx only, over valid lines, rounded to 4 decimalstop_error_path breaks ties alphabetically and is None when there are no 5xx lines"path" is attributed to "unknown"