Group Java stack traces by root cause
Problem statement
A Java service logs one event per line, except that an ERROR event can be followed by a stack trace spanning many lines. A new event always starts with a YYYY-MM-DD HH:MM:SS LEVEL prefix; anything else belongs to the event before it.
For every ERROR event, find its root cause: the last exception in the chain (the last Caused by: line, or the first exception line if there is no Caused by:). An error with no trace counts as (no stack trace). Print each root cause with its count and the timestamp it first appeared. Most frequent first; ties by first appearance.
service.log
2026-09-20 10:00:01 INFO OrderService - order 1001 created2026-09-20 10:00:03 ERROR OrderService - failed to charge cardjava.lang.IllegalStateException: payment gateway returned 502 at com.shop.pay.Gateway.charge(Gateway.java:88) at com.shop.order.OrderService.checkout(OrderService.java:41)Caused by: java.net.SocketTimeoutException: Read timed out at java.base/java.net.SocketInputStream.read(SocketInputStream.java:168) ... 12 more2026-09-20 10:00:04 INFO OrderService - order 1002 created2026-09-20 10:00:09 ERROR InventoryService - stock lookup failedjava.lang.NullPointerException: sku is null at com.shop.inv.Stock.find(Stock.java:23)2026-09-20 10:00:15 ERROR OrderService - failed to charge cardjava.lang.IllegalStateException: payment gateway returned 502 at com.shop.pay.Gateway.charge(Gateway.java:88)Caused by: java.net.SocketTimeoutException: Read timed out ... 9 more2026-09-20 10:00:20 ERROR ReportJob - nightly report skippedExamples
Example 1
Input: python solution.py service.log
Output: 2 java.net.SocketTimeoutException first=2026-09-20 10:00:03
1 java.lang.NullPointerException first=2026-09-20 10:00:09
1 (no stack trace) first=2026-09-20 10:00:20
Explanation: Both payment failures are reported as IllegalStateException, but the real cause is the socket timeout underneath. Grouping by the surface exception would hide that.
Hints
Approach
Optimal
Split the problem in two: turn lines into records, then classify each record.
records()is a generator with one piece of state, the record being built. When a line matchesRECORD_START, it yields the previous record and starts a new one. Otherwise the line is appended. Lines before the first record (the file begins in the middle of a trace after rotation) are counted and ignored. A cap of 500 lines stops a runaway trace from using unbounded memory.root_cause()scans a record's continuation lines and keeps the last line that matches the exception pattern. Frames (at ...) and... 12 moredo not match.- A
Countercounts causes andsetdefaultstores the first timestamp.
Time is linear in lines. Memory is one record (r) plus one entry per distinct cause (c).
O(n)Space O(r + c)import reimport sysfrom collections import Counter RECORD_START = re.compile(r"^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) (\w+)\s")# "java.net.SocketTimeoutException: Read timed out" or "Caused by: ..."EXCEPTION = re.compile(r"^(?:Caused by: )?([A-Za-z_$][\w$]*(?:\.[\w$]+)+)(?::|$)")MAX_LINES = 500 # stop a runaway trace from eating memory def records(lines): """Yield (timestamp, level, [lines]) for each log event, trace lines included.""" current = None orphans = 0 for raw in lines: line = raw.rstrip("\n") m = RECORD_START.match(line) if m: if current: yield current current = (m[1], m[2], [line]) elif current is None: orphans += 1 # file starts mid-trace (e.g. just after rotation) elif len(current[2]) < MAX_LINES: current[2].append(line) if current: yield current if orphans: print(f"ignored {orphans} line(s) before the first record", file=sys.stderr) def root_cause(record_lines): """The last exception in the chain is the one that started it.""" cause = None for line in record_lines[1:]: m = EXCEPTION.match(line) if m: cause = m[1] return cause or "(no stack trace)" def summarize(lines): counts = Counter() first_seen = {} for ts, level, body in records(lines): if level != "ERROR": continue cause = root_cause(body) counts[cause] += 1 first_seen.setdefault(cause, ts) return sorted(counts.items(), key=lambda kv: (-kv[1], first_seen[kv[0]])), first_seen def main(path): with open(path, encoding="utf-8", errors="replace") as f: rows, first_seen = summarize(f) for cause, n in rows: print(f"{n} {cause} first={first_seen[cause]}") if __name__ == "__main__": main(sys.argv[1] if len(sys.argv) > 1 else "service.log")Follow-up questions
- Support Python tracebacks, where the exception is the last line of the block and there is no dot in
ValueError: bad input. - Group by root cause plus the first application frame (
com.shop...), so two different bugs that both raise NullPointerException are counted separately. - The log is a live stream. When is a record finished if no new event arrives? (Flush after a timeout.)
Frequently asked questions
Traces have variable length, so a fixed context size either cuts traces short or pulls in unrelated lines. You need to know where each record ends, which means recognising the start of the next one. That is the whole idea of multi-line log parsing, and it is what log shippers like Fluent Bit and Logstash configure with a multiline start pattern.
During incidents you often get "the service is throwing errors" and a large log. Grouping by root cause turns thousands of lines into two or three distinct problems. Configuring multi-line parsing correctly is also a real task when shipping JVM logs to a central store.
A message such as failed to load com.shop.Config: missing on its own continuation line could match. Here only continuation lines are checked, never the event's first line, which limits the risk. A stricter pattern could require the class name to end in Exception or Error, at the cost of missing custom Throwable names.