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

snippets/extract-fields-from-a-log-line/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
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

snippets/extract-fields-from-a-log-line/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * 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

interactif

Le 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.

à vous d’écrire

Recalculé à chaque frappe.

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 que le code en fait

Ce qu’il renvoie

3 ligne(s) découpée(s)

Le détail

Ligneclientidentusertimestamprequeststatussizereferreragent
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

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

  1. RecommandéN0Motifs nommés à la façon de Grok, et journal des lignes rejetéesGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. 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

Source des chiffres

Métadonnées