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
"""
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
/**
* 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
interactiveThe 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.
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 it returns
3 line(s) parsed
In detail
| Line | client | ident | user | timestamp | request | status | size | referrer | agent |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 192.168.0.12 | - | jean | 10/Oct/2026:13:55:36 +0200 | GET /factures/42 HTTP/1.1 | 200 | 2326 | https://exemple.fr/ | Mozilla/5.0 (X11; Linux x86_64) |
| 2 | 203.0.113.7 | - | - | 10/Oct/2026:13:55:37 +0200 | POST /connexion HTTP/1.1 | 302 | - | - | curl/8.5.0 |
| 3 | 2001:db8::1 | - | marie | 10/Oct/2026:13:55:38 +0200 | GET /café HTTP/1.1 | 404 | 512 | - | Mozilla/5.0 |
Here it breaks
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
- RecommendedN0Named patterns in the Grok style, with a log of the rejected linesFree · Instant
- N1Not worth it here
- N2Not worth it here
- 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
- Grok — la notation des motifs nommés, dans la documentation de Logstash
- Apache — le format de journal combiné
- RFC 3164 — le format historique de syslog, sans année ni fuseau
- RFC 5424 — le format qui lui succède, avec un horodatage complet
Where the figures come from
- RFC 3164, section 4.1.2 (consultée) : l’horodatage est « Mmm dd hh:mm:ss », sans année et sans indication de fuseau horaire
- Apache, documentation des journaux (consultée) : la définition du format combiné, et le tiret écrit à la place d’une taille absente
- Logstash, filtre grok (consulté) : la notation %{SYNTAXE:nom} et la bibliothèque de motifs livrée avec
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.