Reading a log file

Split off the fixed fields, keep the message whole, then key it and count.

The idea

Almost every log question is the same three moves. Split each line into its fixed leading fields and the free text that follows. Choose a key: the level, the service, the minute. Then aggregate by that key.

The moves are easy. What breaks is the splitting, because the message is free text and free text contains whatever the author felt like typing, including the character you are splitting on. So the split has to be bounded: take the fields you know about from the left, and leave the rest alone.

Once the stream is keyed, the same lines answer very different questions depending on how you slice time. Per-minute counts show load. Consecutive failures show an incident. Idle gaps show sessions. All three come from the same pass.

A log line split into fixed fields and a whole message, with per-minute counts, session breaks, and an error streak below

the stream

counts by level

How it works

One bounded split, then a dictionary. The bound is the entire trick.

def parse(line):
    # exactly three splits: timestamp, level, service, and THEN the rest,
    # whole, including any spaces the message happens to contain
    ts, level, service, message = line.split(" ", 3)
    return ts, level, service, message

def by_key(lines, key):
    counts = {}
    for line in lines:
        ts, level, service, _ = parse(line)
        k = {"level": level, "service": service, "minute": ts[:5]}[key]
        counts[k] = counts.get(k, 0) + 1
    return counts

def first_streak(lines, service, n):
    """The first index where `service` has failed n times in a row.
    Any non-error line for that service resets the run to zero."""
    run = 0
    for i, line in enumerate(lines):
        _, level, svc, _ = parse(line)
        if svc != service:
            continue                 # other services do not interrupt the run
        run = run + 1 if level == "ERROR" else 0
        if run == n:
            return i
    return -1

Bucketing by minute is a floor, not a round: second // 60. Rounding puts the second half of every minute into the next one, which shifts every spike on the chart by thirty seconds and makes a traffic graph disagree with the incident timeline.

Cost

QuestionTimeSpaceNote
Counts by keyO(n)O(distinct keys)One pass, one dictionary
Per-minute rateO(n)O(minutes)Empty minutes still have to appear
Longest streakO(n)O(services)A counter per service, reset on success
Sessions by idle gapO(n)O(1)Needs the lines in time order
Top k servicesO(n + m log k)O(m)A heap over the counts, not a full sort

Every one of these is a single pass. If a log question seems to need two, it is usually a sign that the key has not been chosen yet.

Watch out for

Worked example

Take 10:09:14 ERROR billing charge declined card expired, with two spaces in the middle of the message.

An unbounded split gives six tokens, and a parser that trusts positions reports the message as "charge". Split with a count of three and the fourth part is charge declined card expired, double space and all, which is exactly what was logged and exactly what a search for that message will need to match.

Now key the same stream three ways without re-parsing it. By level, the counts say how healthy the window was. By service, they say who is responsible. By minute, they say when. And the run of three consecutive auth errors early in the file is invisible to all three counts: it is a property of the order, not the totals, which is why an incident rule reads the sequence rather than the summary.

Check yourself

You count requests per minute with counts[ts[:5]] += 1 and plot the dictionary. Traffic stopped completely for four minutes during an outage. What does the chart show?