Découper des lignes de journal en champs
Tirer d’une ligne de journal l’adresse, la date, le code de réponse et le reste, pour les compter ou les stocker.
Pas besoin d’IA
GratuitInstantanéVos données ne sortent pas
Le verdict
N0, parce que le format est connu d’avance et que le volume ne laisse pas le choix : quelques microsecondes par ligne contre un appel réseau, sur des millions de lignes par jour. Ce que la fiche apporte au-delà de l’expression régulière tient en trois choses. La notation nommée à la façon de Grok, d’abord, qui permet d’écrire le motif une seule fois pour les deux langages — Python et JavaScript n’orthographient pas le groupe nommé de la même façon. Le journal des rejets ensuite : une ligne qui ne correspond pas n’est jamais perdue, et c’est elle qui vous apprendra que le format a changé. Et le refus de transformer l’horodatage en date quand le format ne porte ni l’année ni le fuseau, ce qui est le cas du syslog de la RFC 3164.
Motifs nommés à la façon de Grok, et journal des lignes rejetées
Pas besoin d’IA
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Découper une ligne de journal en champs, avec un motif nommé.
Niveau N0. Une ligne de journal n'est pas du texte libre : elle a été écrite
par un programme, dans un format fixé par ce programme, et souvent par une
norme. Il n'y a rien à deviner — la forme est connue avant la première ligne,
ce qui est exactement la situation pour laquelle une expression régulière a
été inventée.
Ce qui fait la différence au volume n'est pas l'expression mais le
vocabulaire autour d'elle. Grok, la notation que Logstash a rendue courante,
nomme les morceaux une fois — `%{IP:client}`, `%{INT:status}` — et permet de
décrire une ligne par ses champs plutôt que par un mur de crochets. La
douzaine de lignes ci-dessous, c'est cette idée, avec les morceaux dont cette
fiche a besoin ; les vraies bibliothèques en livrent quelques centaines.
Deux règles pour lesquelles le reste de ce fichier existe. Une ligne qui ne
correspond pas n'est jamais jetée : elle revient dans `rejected`, avec son
numéro, parce qu'un analyseur de journal qui perd en silence une ligne sur
vingt est pire qu'un analyseur qui n'en lit aucune. Et l'horodatage est rendu
tel qu'il est écrit, pas comme une date : le format syslog de la RFC 3164 ne
porte ni année ni fuseau, et faire de « Oct 10 13:55:36 » un instant, c'est
inventer les deux.
"""
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
/**
* Découper une ligne de journal en champs, avec un motif nommé.
*
* Niveau N0. Une ligne de journal n'est pas du texte libre : elle a été écrite
* par un programme, dans un format fixé par ce programme, et souvent par une
* norme. Il n'y a rien à deviner — la forme est connue avant la première ligne,
* ce qui est exactement la situation pour laquelle une expression régulière a
* été inventée.
*
* Ce qui fait la différence au volume n'est pas l'expression mais le
* vocabulaire autour d'elle. Grok, la notation que Logstash a rendue courante,
* nomme les morceaux une fois — `%{IP:client}`, `%{INT:status}` — et permet de
* décrire une ligne par ses champs plutôt que par un mur de crochets. La
* douzaine de lignes ci-dessous, c'est cette idée, avec les morceaux dont cette
* fiche a besoin ; les vraies bibliothèques en livrent quelques centaines.
*
* Deux règles pour lesquelles le reste de ce fichier existe. Une ligne qui ne
* correspond pas n'est jamais jetée : elle revient dans `rejected`, avec son
* numéro, parce qu'un analyseur de journal qui perd en silence une ligne sur
* vingt est pire qu'un analyseur qui n'en lit aucune. Et l'horodatage est rendu
* tel qu'il est écrit, pas comme une date : le format syslog de la RFC 3164 ne
* porte ni année ni fuseau, et faire de « Oct 10 13:55:36 » un instant, c'est
* inventer les deux.
*/
// 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);
}Essayer
interactifLe code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.
Le motif employé est celui du journal d’accès d’Apache, sauf sur le cas syslog. Rien ne part sur le réseau : le découpage se fait dans votre navigateur.
Ou partez d’un exemple
La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.
Ce qu’il renvoie
3 ligne(s) découpée(s)
Le détail
| Ligne | 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 |
Là, ça casse
Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Aucun fournisseur dans la boucle
- Empreinte négligeable
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : la ligne est découpée dans votre processus et n’est transmise à personne
- Un journal d’accès porte des adresses IP, qui sont des données personnelles : ce niveau est celui qui les garde chez vous
Point de rupture
Un motif trop lâche découpe faux sans rien rejeter : décrire l’horodatage **et** l’étiquette par « %{DATA} » rend timestamp « Oct », host « 10 » et tag « 13:55:36 serveur1 sshd[1234] ». Témoin dans le même test : le motif de la fiche rend les quatre champs justes, et `loose_pieces` nomme les morceaux fautifs.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus. Le jour où la liste des lignes rejetées grossit, ce n’est pas un modèle qu’il faut : c’est lire ces lignes, qui sont d’un format que vous n’aviez pas prévu, et écrire le motif qui leur correspond.
Les quatre options
- RecommandéN0Motifs nommés à la façon de Grok, et journal des lignes rejetéesGratuit · Instantané
- N1Niveau sans objet ici
- N2Niveau sans objet ici
- N3Niveau sans objet ici
Les autres options
N1Niveau sans objet ici
Niveau sans objet ici
Modèle classique légerN1
Il n’y a rien à apprendre : le format est écrit dans le code du programme qui produit le journal, et il est publié pour les formats courants. Un modèle appris sur un échantillon apprendrait la forme des lignes déjà vues, alors que la seule chose intéressante dans un journal est la ligne qu’on n’a jamais vue. Le motif, lui, la rejette et la montre.
N2Niveau sans objet ici
Niveau sans objet ici
Petit modèle spécialisé auto-hébergéN2
Même argument, avec le budget en plus. Ce traitement est appelé une fois par ligne sur un flux qui se compte en millions par jour ; la mesure de cette fiche le situe à quelques microsecondes la ligne. Un modèle, même petit, même local, coûte des ordres de grandeur de plus par ligne, pour retrouver un découpage que le format donne exactement.
N3Niveau sans objet ici
Niveau sans objet ici
API de LLM généralisteN3
Un appel de modèle par ligne de journal est l’exemple que ce site existe pour nommer : au volume d’un journal d’accès, c’est une facture par ligne, une latence par ligne, et l’envoi chez un tiers d’adresses IP et de chemins d’URL qui sont vos données et celles de vos visiteurs. Le format, lui, est écrit dans la documentation du serveur qui l’a produit.
Pour aller plus loin
- 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
Source des chiffres
- 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
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.