Split log lines into fields

Pull the address, the date, the status code and the rest out of a log line, to count them or store them.

No AI needed

FreeInstantYour data never leaves

The verdict

N0, because the format is known in advance and volume leaves no choice: a few microseconds per line against a network call, over millions of lines a day. What this entry adds beyond the regular expression comes down to three things. The Grok-style named notation first, which lets the pattern be written once for both languages — Python and JavaScript do not spell a named group the same way. The rejects log next: a line that does not match is never lost, and it is the one that will tell you the format changed. And the refusal to turn the timestamp into a date when the format carries neither the year nor the time zone, which is the case of RFC 3164 syslog.

Named patterns in the Grok style, with a log of the rejected lines

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/extract-fields-from-a-log-line/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Cut a log line into fields with a named pattern.

Rung N0. A log line is not free text: it was written by a program, with a
format fixed by that program, and often by a standard. There is nothing to
infer — the shape is known before the first line arrives, which is exactly the
situation a regular expression was invented for.

What makes the difference at volume is not the expression but the vocabulary
around it. Grok, the notation Logstash made common, names the pieces once —
`%{IP:client}`, `%{INT:status}` — and lets a line be described by its fields
rather than by a wall of brackets. The dozen lines below are that idea, with
the pieces this entry needs; the real libraries ship a few hundred of them.

Two rules the rest of this file exists for. A line that does not match is never
dropped: it comes back in `rejected`, with its number, because a log parser
that quietly loses one line in twenty is worse than one that parses none. And
the timestamp is returned as it was written, not as a date: the RFC 3164 syslog
format carries neither year nor time zone, and turning « Oct 10 13:55:36 » into
an instant means inventing both.
"""

from __future__ import annotations

import re

# The pieces a pattern is built from. Deliberately few and deliberately loose:
# a log parser that refuses a line because an address was unusual has lost the
# line, and the line is the point.
PIECES = {
    "IP": r"[0-9A-Fa-f.:]+",
    "WORD": r"\S+",
    "INT": r"-?\d+",
    "DATA": r".*?",
    # A quoted field, escapes included: `mod_log_config` writes a `\"` inside
    # the request and the agent, and scanners produce them every day. Reading
    # up to the first quote would send those lines to the reject list.
    "QUOTED": r"(?:[^\"\\]|\\.)*",
    "BRACKETED": r"[^\]]*",
    "NOTCOLON": r"[^:]+",
    "SYSLOGDATE": r"\w{3}\s+\d{1,2} \d{2}:\d{2}:\d{2}",
    "GREEDY": r".*",
}

# A loose piece is one that can match anything, so it cuts wherever the rest of
# the pattern lets it. Placed before the end, it moves the boundary of every
# field that follows without rejecting a single line — which is this entry's
# breaking point. `loose_pieces` in the report names them.
LOOSE = ("DATA", "GREEDY")

# Three formats that cover most of what lands in a log directory. A pattern is
# written once, in this notation, and compiled for the language that reads it —
# Python spells a named group `(?P<name>…)` and JavaScript `(?<name>…)`, which
# is reason enough not to write either by hand in a file read by both.
#
# The syslog priority between angle brackets exists on the wire — the UDP 514
# packet — and the collector strips it before writing. A line of
# `/var/log/syslog`, of `/var/log/auth.log` or of `journalctl` has none, so the
# piece is optional: a pattern that required it rejected every line of the log
# directory of every machine.
PATTERNS = {
    "apache-combined": (
        r"%{IP:client} %{WORD:ident} %{WORD:user} \[%{BRACKETED:timestamp}\]"
        r' "%{QUOTED:request}" %{INT:status} %{WORD:size}'
        r' "%{QUOTED:referrer}" "%{QUOTED:agent}"'
    ),
    "syslog-3164": (
        r"(?:<%{INT:priority}>)?%{SYSLOGDATE:timestamp} %{WORD:host}"
        r" %{NOTCOLON:tag}: %{GREEDY:message}"
    ),
    "nginx-error": (
        r"%{BRACKETED:timestamp} \[%{BRACKETED:level}\] %{WORD:pid}: %{GREEDY:message}"
    ),
}

NAMED = re.compile(r"%\{(\w+):(\w+)\}")


def compile_pattern(pattern: str):
    """Turn the named notation into a regular expression of this language."""
    def piece(match):
        kind, name = match.group(1), match.group(2)
        if kind not in PIECES:
            raise KeyError(f"unknown piece {kind}")
        return f"(?P<{name}>{PIECES[kind]})"

    return re.compile(f"^{NAMED.sub(piece, pattern)}$")


def loose_pieces(pattern: str) -> list:
    """
    The loose pieces of a pattern that are not its last one.

    A loose piece in last position stops where the line does, which is what it
    is for. Anywhere else it decides the boundary of the fields that follow,
    and it decides it silently.
    """
    found = NAMED.findall(pattern)
    return [name for kind, name in found[:-1] if kind in LOOSE]


def parse_lines(lines, pattern: str) -> dict:
    """
    Every line cut into its fields, and every line that could not be.

    `pattern` is a name from `PATTERNS` or a pattern written in the same
    notation. `rejected` carries the line number and the line itself: that list
    is what tells you the format changed under you.

    `loose_pieces` names the pieces that can cut anywhere. Nothing is rejected
    because of them — that is exactly the problem — so they are named instead.
    """
    written = PATTERNS.get(pattern, pattern)
    try:
        expression = compile_pattern(written)
    except (KeyError, re.error):
        return {"parsed": [], "rejected": [], "loose_pieces": [],
                "reason": "this pattern is not usable"}

    parsed, rejected = [], []
    for number, line in enumerate(lines, start=1):
        if not isinstance(line, str):
            rejected.append({"line": number, "text": None})
            continue
        found = expression.match(line.rstrip("\r\n"))
        if found:
            parsed.append({"line": number, **found.groupdict()})
        else:
            rejected.append({"line": number, "text": line.rstrip("\r\n")})
    return {"parsed": parsed, "rejected": rejected,
            "loose_pieces": loose_pieces(written), "reason": None}

JavaScript

snippets/extract-fields-from-a-log-line/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Cut a log line into fields with a named pattern.
 *
 * Rung N0. A log line is not free text: it was written by a program, with a
 * format fixed by that program, and often by a standard. There is nothing to
 * infer — the shape is known before the first line arrives, which is exactly
 * the situation a regular expression was invented for.
 *
 * What makes the difference at volume is not the expression but the vocabulary
 * around it. Grok, the notation Logstash made common, names the pieces once —
 * `%{IP:client}`, `%{INT:status}` — and lets a line be described by its fields
 * rather than by a wall of brackets. The dozen lines below are that idea, with
 * the pieces this entry needs; the real libraries ship a few hundred of them.
 *
 * Two rules the rest of this file exists for. A line that does not match is
 * never dropped: it comes back in `rejected`, with its number, because a log
 * parser that quietly loses one line in twenty is worse than one that parses
 * none. And the timestamp is returned as it was written, not as a date: the
 * RFC 3164 syslog format carries neither year nor time zone, and turning
 * « Oct 10 13:55:36 » into an instant means inventing both.
 */

// The pieces a pattern is built from. Deliberately few and deliberately loose:
// a log parser that refuses a line because an address was unusual has lost the
// line, and the line is the point.
export const PIECES = {
  IP: '[0-9A-Fa-f.:]+',
  WORD: '\\S+',
  INT: '-?\\d+',
  DATA: '.*?',
  // A quoted field, escapes included: `mod_log_config` writes a `\"` inside
  // the request and the agent, and scanners produce them every day. Reading up
  // to the first quote would send those lines to the reject list.
  QUOTED: '(?:[^"\\\\]|\\\\.)*',
  BRACKETED: '[^\\]]*',
  NOTCOLON: '[^:]+',
  SYSLOGDATE: '\\w{3}\\s+\\d{1,2} \\d{2}:\\d{2}:\\d{2}',
  GREEDY: '.*',
};

// A loose piece is one that can match anything, so it cuts wherever the rest
// of the pattern lets it. Placed before the end, it moves the boundary of
// every field that follows without rejecting a single line — which is this
// entry's breaking point. `loose_pieces` in the report names them.
export const LOOSE = ['DATA', 'GREEDY'];

// Three formats that cover most of what lands in a log directory. A pattern is
// written once, in this notation, and compiled for the language that reads it —
// Python spells a named group `(?P<name>…)` and JavaScript `(?<name>…)`, which
// is reason enough not to write either by hand in a file read by both.
//
// The syslog priority between angle brackets exists on the wire — the UDP 514
// packet — and the collector strips it before writing. A line of
// `/var/log/syslog`, of `/var/log/auth.log` or of `journalctl` has none, so
// the piece is optional: a pattern that required it rejected every line of the
// log directory of every machine.
export const PATTERNS = {
  'apache-combined':
    '%{IP:client} %{WORD:ident} %{WORD:user} \\[%{BRACKETED:timestamp}\\]'
    + ' "%{QUOTED:request}" %{INT:status} %{WORD:size}'
    + ' "%{QUOTED:referrer}" "%{QUOTED:agent}"',
  'syslog-3164':
    '(?:<%{INT:priority}>)?%{SYSLOGDATE:timestamp} %{WORD:host}'
    + ' %{NOTCOLON:tag}: %{GREEDY:message}',
  'nginx-error':
    '%{BRACKETED:timestamp} \\[%{BRACKETED:level}\\] %{WORD:pid}: %{GREEDY:message}',
};

const NAMED = /%\{(\w+):(\w+)\}/g;

/** Turn the named notation into a regular expression of this language. */
export function compilePattern(pattern) {
  const body = pattern.replace(NAMED, (whole, kind, name) => {
    if (!(kind in PIECES)) throw new Error(`unknown piece ${kind}`);
    return `(?<${name}>${PIECES[kind]})`;
  });
  return new RegExp(`^${body}$`);
}

/**
 * Every line cut into its fields, and every line that could not be.
 *
 * `pattern` is a name from `PATTERNS` or a pattern written in the same
 * notation. `rejected` carries the line number and the line itself: that list
 * is what tells you the format changed under you.
 *
 * `loose_pieces` names the pieces that can cut anywhere. Nothing is rejected
 * because of them — that is exactly the problem — so they are named instead.
 *
 * @param {Iterable<string>} lines
 * @param {string} pattern
 */
export function parseLines(lines, pattern) {
  const written = PATTERNS[pattern] ?? pattern;
  let expression;
  try {
    expression = compilePattern(written);
  } catch {
    return {
      parsed: [], rejected: [], loose_pieces: [], reason: 'this pattern is not usable',
    };
  }

  const parsed = [];
  const rejected = [];
  let number = 0;
  for (const line of lines) {
    number += 1;
    if (typeof line !== 'string') {
      rejected.push({ line: number, text: null });
      continue;
    }
    const text = line.replace(/\r?\n$/, '');
    const found = expression.exec(text);
    // An optional piece that did not match is `undefined` here and `None` in
    // Python; both sides of this entry answer null.
    if (found) {
      const fields = Object.fromEntries(
        Object.entries(found.groups).map(([name, value]) => [name, value ?? null]),
      );
      parsed.push({ line: number, ...fields });
    } else rejected.push({ line: number, text });
  }
  return { parsed, rejected, loose_pieces: loosePieces(written), reason: null };
}

/**
 * The loose pieces of a pattern that are not its last one.
 *
 * A loose piece in last position stops where the line does, which is what it
 * is for. Anywhere else it decides the boundary of the fields that follow, and
 * it decides it silently.
 */
export function loosePieces(pattern) {
  const found = [...pattern.matchAll(NAMED)];
  return found.slice(0, -1).filter(([, kind]) => LOOSE.includes(kind)).map(([, , name]) => name);
}

Try it

interactive

The code above runs in your browser. Type in the box: the result is redone in front of you.

The pattern used is Apache’s access log one, except on the syslog case. Nothing goes out on the network: the parsing happens in your browser.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

What it returns

3 line(s) parsed

In detail

Lineclientidentusertimestamprequeststatussizereferreragent
1192.168.0.12-jean10/Oct/2026:13:55:36 +0200GET /factures/42 HTTP/1.12002326https://exemple.fr/Mozilla/5.0 (X11; Linux x86_64)
2203.0.113.7--10/Oct/2026:13:55:37 +0200POST /connexion HTTP/1.1302--curl/8.5.0
32001:db8::1-marie10/Oct/2026:13:55:38 +0200GET /café HTTP/1.1404512-Mozilla/5.0

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the line is split inside your own process and is sent to nobody
  • An access log carries IP addresses, which are personal data: this is the level that keeps them at home

Breaking point

A pattern that is too loose splits the line wrongly and rejects nothing: describing the timestamp **and** the tag as “%{DATA}” returns timestamp “Oct”, host “10” and tag “13:55:36 serveur1 sshd[1234]”. Witness in the same test: this entry’s pattern returns all four fields correctly, and `loose_pieces` names the offending pieces.

When to move up a level

There is no level above this one. The day the list of rejected lines grows, a model is not what you need: it is reading those lines, which are in a format you had not planned for, and writing the pattern that fits them.

The four options

  1. RecommendedN0Named patterns in the Grok style, with a log of the rejected linesFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. N3Not worth it here

The other options

N1Not worth it here

Not worth it here

Lightweight classic modelN1

There is nothing to learn: the format is written in the code of the program that produces the log, and it is published for the common formats. A model learned on a sample would learn the shape of the lines already seen, while the only interesting thing in a log is the line nobody has seen before. The pattern, for its part, rejects it and shows it.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

The same argument, with budget on top. This runs once per line over a stream counted in millions a day; this entry measures it at a few microseconds per line. A model, however small and however local, costs orders of magnitude more per line, to recover a split the format gives exactly.

N3Not worth it here

Not worth it here

General-purpose LLM APIN3

One model call per log line is the example this site exists to name: at the volume of an access log, that is a bill per line, latency per line, and the sending to a third party of IP addresses and URL paths that are your data and your visitors’. The format, meanwhile, is written in the documentation of the server that produced it.

Further reading

Where the figures come from

Metadata