Aller au contenu
awk : agréger, joindre et produire un rapport

awk : agréger, joindre et produire un rapport

200 Compagnon ⏱ 1 h 50 awkbashlinuxdebianubuntu

À la fin, vous saurez

  • Compter, sommer et calculer des taux par clé, simple ou composée, avec les tableaux associatifs d'awk, sans créer de clé par accident
  • Calculer une moyenne, des extrêmes et des centiles, et expliquer pourquoi la moyenne d'un temps de réponse peut tromper
  • Joindre un fichier de référence et des fichiers de données avec l'idiome NR == FNR, et en connaître le cas limite
  • Lire et écrire en dehors du flux principal avec getline, print > fichier et print | commande, en testant les codes de retour et en fermant ce que l'on ouvre
  • Découper un CSV conforme à la RFC 4180 en awk POSIX, et choisir entre cette fonction, les extensions de gawk et un autre outil
  • Produire un rapport trié et reproductible, identique sous mawk et sous gawk

Prérequis

Testé avec coreutils 9.4 (Ubuntu 24.04), 9.7 (Debian 13) gawk 5.2.1 (Ubuntu 24.04 et Debian 13) mawk 1.3.4-20240123 (Ubuntu 24.04), 1.3.4-20250131 (Debian 13) , vérifié le 9 octobre 2026

Pourquoi

Jeudi 8 octobre, 16 h. La leçon 5 a laissé dans le dépôt signalements-outils un programme, lib/requetes.awk, qui transforme les lignes de l'API perdues au milieu de syslog.log en un TSV propre : date, heure, hôte, méthode, chemin, code. C'était le travail ligne à ligne : regarder une ligne, décider si elle compte, en extraire des champs.

Les deux demandes du jour ne portent plus sur des lignes, mais sur des bilans. L'équipe veut savoir quel serveur a renvoyé combien d'erreurs, quel jour, et quelle proportion du trafic cela représente. La mairie veut un tableau par commune : combien de signalements, combien pour mille habitants, quel type domine. Aucune de ces réponses ne se lit sur une ligne : il faut accumuler pendant la lecture, puis restituer à la fin.

Deux tentatives faites dans l'après-midi montrent ce qui se passe quand on improvise.

La première vient d'un collègue pressé, qui a voulu compter les signalements par commune « comme d'habitude » :

$ awk -F, 'FNR > 1 { n[$3]++ } END { for (c in n) print n[c], c }' exports/*.csv
52 "Saint-Exemple
204 Exempleville
48 Val-d'Essai
17 Les Essarts-du-Test
30 Bourg-Témoin

Le total est juste (351), mais une commune s'appelle désormais "Saint-Exemple, avec un guillemet et sans « centre ». Collé tel quel dans le tableur de la mairie, ce nom ne correspondra à aucune ligne de son référentiel, et la commune disparaîtra du graphique. Le cours Bash l'annonçait déjà : -F, découpe sur chaque virgule et ignore les guillemets du CSV.

La seconde est un message posté dans le canal de l'équipe : « temps de réponse moyen de sig-app-2 cette semaine : 1,4 seconde ». C'est exact. C'est aussi trompeur : la réponse type prend une trentaine de millisecondes, et cent requêtes bloquées trente secondes pendant l'incident de mercredi suffisent à multiplier la moyenne par quarante.

Cette leçon apprend à faire ces bilans correctement avec awk : les tableaux associatifs, qui sont la seule structure de données du langage et suffisent à presque tout ; la jointure de deux fichiers ; la lecture et l'écriture hors du flux principal ; les statistiques qui ne mentent pas ; et la lecture d'un CSV digne de ce nom en awk portable. À la fin, deux programmes rejoignent lib/ : rapport.awk pour le volet technique, communes.awk pour le volet « mairie ». La leçon 8 les assemblera dans rapport-hebdo.

Les concepts

Le tableau associatif d'awk

awk n'a qu'une structure de données : le tableau associatif (associative array), une collection de paires clé et valeur où la clé est une chaîne quelconque. Il n'y a pas de tableau indexé à part : t[1] est l'élément de clé "1", et la norme POSIX précise que les indices numériques sont convertis en chaînes. Les tableaux n'ont pas besoin d'être déclarés, ils n'ont pas de taille fixe, et un élément naît à sa première utilisation.

n[$3]++                 # compte les lignes par valeur du troisième champ
octets[$1] += $10       # somme le dixième champ par valeur du premier
dernier[$2] = $0        # garde la dernière ligne vue pour chaque valeur du deuxième

Ces trois lignes contiennent l'essentiel de l'agrégation en awk. n[$3]++ fonctionne dès la première ligne parce qu'une valeur non initialisée vaut, d'après POSIX, à la fois le nombre zéro et la chaîne vide : l'incrément part de 0.

Si vous avez suivi la leçon 7 du cours Bash, vous connaissez les tableaux associatifs de Bash (declare -A). Ceux d'awk sont plus simples à utiliser (aucune déclaration, aucun "${!t[@]}"), beaucoup plus rapides, et ils vivent le temps d'un seul processus qui lit tout le flux : c'est ce qui les rend adaptés aux gros volumes.

Parcourir, tester, supprimer

Trois opérations complètent l'affectation :

  • for (cle in t) parcourt les clés. La norme dit que l'ordre est non spécifié, et qu'ajouter des éléments pendant le parcours a des effets non définis. En pratique, l'ordre dépend de la table de hachage interne : il change d'une implémentation à l'autre, et parfois d'un jeu de données à l'autre.
  • (cle in t) est une expression qui vaut 1 si la clé existe, 0 sinon, sans la créer.
  • delete t[cle] supprime un élément ; delete t (sans indice) vide tout le tableau. Longtemps extension commune, cette seconde forme est normalisée par POSIX.1-2024, de même que length(t), qui renvoie le nombre d'éléments.

Le point important est le deuxième. La norme est explicite : l'opérateur in ne crée pas l'élément, mais « toute autre référence à un élément inexistant le crée automatiquement ». Écrire if (t["b"] == "") pour savoir si b existe crée b, avec une valeur vide. Le test répond « absent », et l'élément est désormais présent : il apparaîtra dans le prochain for (k in t), et dans le décompte de length(t).

Clés composées et SUBSEP

On a souvent besoin d'une clé à plusieurs dimensions : un compteur par hôte et par jour, par commune et par type. awk le permet avec une virgule dans l'indice :

n[$3, $1]++                       # par hôte et par jour
if (("sig-app-2", "2026-10-07") in n) ...   # test : parenthèses obligatoires

Il n'y a pourtant pas de vrai tableau à deux dimensions. awk concatène les indices en les séparant par la valeur de la variable SUBSEP, et range le tout sous une seule clé. SUBSEP vaut par défaut le caractère de code 034 en octal (28 en décimal, le « séparateur de fichiers » d'ASCII), choisi parce qu'il n'apparaît presque jamais dans du texte. POSIX laisse cette valeur à l'implémentation ; mawk et gawk utilisent tous deux \034. Pour récupérer les composantes d'une clé lors d'un parcours, on la redécoupe :

for (cle in n) {
    split(cle, partie, SUBSEP)
    print partie[1], partie[2], n[cle]
}

gawk propose en plus de vrais tableaux de tableaux (t[i][j]) : ils ne sont pas portables, et mawk ne les connaît pas.

Compter, sommer, encadrer

Presque toutes les agrégations suivent un des quatre gabarits suivants, à combiner :

MesurePendant la lectureÀ la fin
Effectifn[k]++n[k]
Sommes[k] += $xs[k]
Moyennes[k] += $x; n[k]++s[k] / n[k]
Tauxn[k]++; if (condition) e[k]++100 * e[k] / n[k]
Minimum, maximumif (!(k in min) || $x < min[k]) min[k] = $xmin[k], max[k]

Le minimum mérite un mot. L'écriture naïve if ($x < min) min = $x ne fonctionne jamais : min vaut 0 au départ, et aucune valeur positive n'est plus petite. Il faut initialiser avec la première valeur, d'où le test !(k in min), ou, sans clé, NR == 1 || $x < min.

Moyenne, médiane, centiles

La moyenne additionne toutes les valeurs : une seule valeur extrême pèse autant que des milliers de valeurs ordinaires. Pour un temps de réponse, on lui préfère les centiles (percentiles) : le centile 95, noté p95, est la valeur sous laquelle se trouvent 95 % des mesures ; la médiane est le centile 50. Ils disent ce que vivent les usagers : « la moitié des requêtes répondent en moins de 31 ms, 95 % en moins de 144 ms, mais près de 5 % attendent 30 secondes » raconte l'incident, là où « 1,4 s en moyenne » ne décrit personne.

Calculer un centile exige de trier toutes les valeurs, puis de prendre celle du bon rang. awk POSIX n'a pas de fonction de tri. Deux solutions : trier en dehors d'awk, avec sort -n, et lire le résultat trié ; ou écrire une fonction de tri dans le programme, raisonnable pour quelques milliers de valeurs. Pour le rang, la méthode la plus simple, dite du rang le plus proche (nearest rank), prend la valeur de rang ⌈q × n⌉ : pour n = 2 072 et q = 0,95, la 1 969e valeur. D'autres méthodes interpolent entre deux valeurs ; elles donnent des résultats légèrement différents. L'important est de dire laquelle on utilise, et de ne pas en changer d'une semaine à l'autre.

Dédoublonner : !vu[$0]++

C'est l'idiome le plus célèbre d'awk, et il tient en un motif sans action :

!vu[$0]++

Pour chaque ligne, vu[$0]++ renvoie la valeur avant incrément : 0 la première fois que cette ligne est vue, 1 ou plus ensuite. ! inverse : le motif est vrai à la première occurrence seulement, et l'action par défaut (afficher la ligne) s'applique. Le résultat est l'équivalent de sort -u, à deux différences près : l'ordre d'origine est conservé, et la sortie commence avant la fin de l'entrée. La clé n'est pas forcément la ligne entière : !vu[$8]++ garde la première ligne de chaque valeur du huitième champ.

Le prix est la mémoire : chaque clé distincte reste dans le tableau jusqu'à la fin.

Deux fichiers : la jointure

Une jointure (join) associe les enregistrements de deux sources qui partagent une clé : ici, la commune d'un signalement et sa ligne dans le référentiel, qui donne la population. join, vu en leçon 1, le fait sur deux fichiers triés ; awk le fait sans tri, en chargeant le plus petit fichier en mémoire. L'idiome repose sur deux variables : NR, le numéro de l'enregistrement depuis le début de l'exécution, et FNR, le numéro dans le fichier courant. Elles sont égales tant que l'on lit le premier fichier, et seulement lui.

NR == FNR { population[$2] = $3; next }   # premier fichier : on mémorise
($3 in population) { ... }                # fichiers suivants : on consulte

next est indispensable : sans lui, les lignes du premier fichier traverseraient aussi les règles destinées aux suivants.

L'idiome a un cas limite connu : si le premier fichier est vide, NR == FNR reste vrai pour tout le deuxième fichier, qui est alors chargé comme référentiel. On le verra en pratique, avec l'alternative FILENAME == ARGV[1].

getline : lire ailleurs

awk lit son entrée tout seul, enregistrement par enregistrement. getline permet de lire en plus, à un autre endroit. Il en existe plusieurs formes, qui ne modifient pas les mêmes variables :

FormeLit depuisModifie
getlinel'entrée principale$0, NF, NR, FNR
getline varl'entrée principalevar, NR, FNR
getline < fichierun fichier$0, NF
getline var < fichierun fichiervar seulement
commande | getlinela sortie d'une commande$0, NF, NR
commande | getline varla sortie d'une commandevar, NR

Toutes renvoient, d'après POSIX, 1 si une ligne a été lue, 0 à la fin du fichier et −1 en cas d'erreur (fichier absent, illisible). Ce troisième cas est la source du piège le plus classique d'awk : while (getline ligne < f) teste la vérité de la valeur de retour, et −1 est vrai. Sur un fichier absent, la boucle ne s'arrête jamais. La seule forme correcte est while ((getline ligne < f) > 0).

Le manuel de gawk consacre une page entière, Points About getline to Remember, à ce qui surprend : getline sans redirection avance dans le fichier courant (et la règle suivante ne voit pas la ligne sautée), le fichier reste ouvert tant qu'on ne le ferme pas, et une commande qui sert à getline est lancée par /bin/sh. Dans un programme d'agrégation, on s'en sert surtout dans BEGIN, pour charger un fichier annexe ou récupérer une valeur calculée par une commande.

Écrire ailleurs : fichiers, commandes, close et fflush

Symétriquement, print et printf peuvent écrire ailleurs que sur la sortie standard :

print > "erreurs.txt"          # ouvre et vide le fichier au premier print, puis ajoute
print >> "erreurs.txt"         # ouvre en ajout
print | "sort -n"              # envoie à une commande, lancée une seule fois
print > "/dev/stderr"          # erreur standard

Trois règles gouvernent ces redirections :

  1. Une redirection est identifiée par sa chaîne. Le premier print > "a.txt" ouvre le fichier (et le vide) ; les suivants, avec la même chaîne, écrivent dans le fichier déjà ouvert. De même, print | "sort -n" ne lance sort qu'une fois, et toutes les lignes lui parviennent.
  2. Ce qui est ouvert reste ouvert jusqu'à close(chaine) ou la fin du programme. La norme laisse le nombre maximal de fichiers et de commandes ouverts à l'implémentation ; en pratique, c'est la limite de descripteurs de fichier du processus (ulimit -n). close rend aussi le moyen de relire un fichier depuis le début, ou d'attendre la fin d'une commande : close("sort -n") ferme son entrée, et sort, qui attendait la fin de ses données pour trier, écrit alors son résultat.
  3. L'ordre des sorties n'est pas garanti entre la sortie standard d'awk, mise en tampon, et celle d'une commande lancée par awk, qui écrit de son côté. fflush(), normalisé par POSIX.1-2024, vide les tampons d'awk au moment voulu.

/dev/stderr n'est pas cité par POSIX. mawk et gawk le reconnaissent comme un nom spécial, et sous Linux il existe de toute façon dans le système de fichiers (un lien vers /proc/self/fd/2) : tout awk y écrit donc sur l'erreur standard. La forme strictement portable est print ... | "cat 1>&2".

Fonctions

Un programme d'agrégation un peu sérieux a besoin de fonctions : découper un CSV, trier une liste, calculer un rang. awk les définit ainsi :

function rang(q, n,    r) {
    r = q * n
    return (r == int(r)) ? r : int(r) + 1
}

Deux particularités. D'abord, awk n'a pas de variables locales au sens habituel : toute variable utilisée dans une fonction est globale, sauf ses paramètres. La convention, que recommandent le manuel de gawk et le livre des auteurs, consiste à déclarer les variables de travail comme paramètres supplémentaires, que l'appelant ne fournit pas, séparés des vrais paramètres par quelques espaces : ici, r est une variable locale. Ensuite, les scalaires sont passés par valeur et les tableaux par référence : une fonction peut remplir un tableau que l'appelant lui donne, ce qui est la seule façon de « renvoyer » plusieurs valeurs. Enfin, aucun espace n'est permis entre le nom d'une fonction définie par l'utilisateur et sa parenthèse ouvrante lors de l'appel.

Ce que gawk ajoute

Les serveurs de l'API (sig-app-1, sig-app-2) sont des images cloud d'Ubuntu, où awk désigne gawk par le jeu des alternatives ; sig-outils, une Debian 13 minimale, n'a que mawk. Un programme qui doit tourner partout s'en tient donc à POSIX. Il faut pourtant connaître les extensions de gawk, ne serait-ce que pour reconnaître un programme qui en dépend :

Extension de gawkRôleÉquivalent POSIX dans cette leçon
PROCINFO["sorted_in"] = "@ind_str_asc"parcourir for (k in t) dans un ordre choisisortie envoyée à sort, ou fonction de tri
asort(t), asorti(t)trier les valeurs ou les indices d'un tableaufonction trier() écrite à la main
FPATdéfinir les champs par leur contenu (CSV)fonction csv_champs()
--csv (-k), gawk 5.3 et pluslecture CSV intégrée, guillemets et sauts de ligne comprisfonction csv_champs(), ou un autre outil
gensub()substitution avec groupes capturésmatch(), substr(), sub()
strftime(), mktime(), systime()datesaussi dans mawk 1.3.4 (extension), sinon date
-i inplacemodifier un fichier sur placefichier temporaire puis mv
tableaux de tableaux t[i][j]structures imbriquéesclés composées avec SUBSEP

Le manuel de gawk le dit sans détour : la section sur FPAT reste utile « pour les versions de gawk antérieures à la 5.3 ». Or Debian 13 et Ubuntu 24.04 livrent toutes deux gawk 5.2.1 : --csv n'y existe pas. Le manuel de mawk, de son côté, signale que mktime, strftime et systime sont des extensions reprises de gawk.

Le CSV, encore

La RFC 4180 décrit le CSV courant : des champs séparés par des virgules, un champ pouvant être entouré de guillemets doubles, auquel cas il peut contenir des virgules, des sauts de ligne, et des guillemets doublés ("" pour un guillemet). Le module csv de Python, qui produit les exports de Signalements, entoure de guillemets tout champ qui contient une virgule, d'où "Saint-Exemple, centre".

Aucun séparateur ne sait découper ce format : le sens d'une virgule dépend de ce qui précède. Il faut lire caractère par caractère en se souvenant si l'on est « entre guillemets ». C'est ce que fait l'option --csv de gawk 5.3, ajoutée en 2023 dans le sillage du awk de Brian Kernighan ; c'est ce que fait la fonction csv_champs() écrite plus bas, en POSIX, avec une restriction assumée : pas de saut de ligne à l'intérieur d'un champ, ce que nos exports ne produisent pas.

En pratique

Les sorties ont été produites avec mawk 1.3.4-20240123 et LC_ALL=C.UTF-8, dans le bac à sable créé à la leçon 1 par preparer-donnees. Les commandes se lancent depuis sa racine ; lib/ y désigne le répertoire du dépôt signalements-outils, grâce au lien symbolique créé à la leçon 5.

Le point de départ : le TSV de la leçon 5

$ awk -f lib/requetes.awk journaux/*/syslog.log > requetes.tsv
$ head -3 requetes.tsv
2026-10-05	00:00:07	sig-app-1	GET	/sante	200
2026-10-05	00:01:07	sig-app-1	GET	/sante	200
2026-10-05	00:02:07	sig-app-1	GET	/sante	200
$ wc -l requetes.tsv
15880 requetes.tsv

Six champs séparés par des tabulations : date, heure, hôte, méthode, chemin (avec sa chaîne de requête), code HTTP. Garder ce fichier TSV intermédiaire est commode pour l'enquête ; dans le rapport final, la sortie de requetes.awk passera directement par un tube.

Un premier taux d'erreurs

Combien de requêtes d'usagers par hôte, combien d'erreurs serveur, quel taux ?

$ awk -F'\t' '$5 != "/sante" { n[$3]++; if ($6 >= 500) e[$3]++ }
    END { for (h in n) printf "%s %d %d %.2f\n", h, n[h], e[h], 100 * e[h] / n[h] }' requetes.tsv | sort
sig-app-1 2120 7 0.33
sig-app-2 2240 107 4.78
  • -F'\t' : le séparateur est la tabulation ; awk interprète lui-même la séquence \t.
  • $5 != "/sante" écarte les vérifications de santé du répartiteur, une par minute et par serveur : elles ne sont pas du trafic d'usagers, et elles diluent les taux (5 760 requêtes de santé contre environ 2 200 requêtes réelles par serveur).
  • $6 >= 500 : le code vient de l'entrée, c'est donc une chaîne numérique (strnum dans le manuel de gawk), comparée comme un nombre à 500. La leçon 5 a détaillé cette règle.
  • e[h] n'existe pas pour un hôte sans erreur : dans printf, il vaut 0. (Et il est créé au passage, sans conséquence ici.)
  • sort final : l'ordre de for (h in n) n'est pas garanti.

La semaine entière dilue l'incident : 4,78 %. La mairie veut un chiffre par semaine, l'équipe veut voir quand.

Par hôte et par jour : rapport.awk

La clé devient composée, hôte et jour. Voici le volet technique du rapport, lib/rapport.awk, en entier :

# rapport.awk : volet technique du rapport hebdomadaire de Signalements.
# Entrée : le TSV de requetes.awk (date, heure, hôte, méthode, chemin, code).
# Sortie : un tableau Markdown, une ligne par hôte et par jour, triée.
# Usage : awk -f lib/requetes.awk journaux/*/syslog.log | awk -f lib/rapport.awk
# Code de sortie : 0, ou 1 si une ligne mal formée a été ignorée.

BEGIN {
    FS = "\t"
    tri = "LC_ALL=C sort -t '|' -k2,2 -k3,3"
}

# Les vérifications de santé du répartiteur ne sont pas du trafic d'usagers.
$5 == "/sante" { next }

# Une ligne qui n'a pas six champs ne vient pas de requetes.awk : on la signale.
NF != 6 {
    printf "rapport.awk : ligne %d ignorée (%d champs)\n", NR, NF > "/dev/stderr"
    anomalies++
    next
}

{
    cle = $3 SUBSEP $1
    requetes[cle]++
    if ($6 ~ /^4/) clients[cle]++
    else if ($6 ~ /^5/) serveur[cle]++
}

END {
    print "| Hôte | Jour | Requêtes | 4xx | 5xx | Taux 5xx |"
    print "|---|---|---:|---:|---:|---:|"
    fflush()
    for (cle in requetes) {
        split(cle, partie, SUBSEP)
        printf("| %s | %s | %d | %d | %d | %.2f %% |\n", partie[1], partie[2],
            requetes[cle], clients[cle], serveur[cle],
            100 * serveur[cle] / requetes[cle]) | tri
    }
    close(tri)
    exit anomalies > 0
}
$ awk -f lib/rapport.awk requetes.tsv
| Hôte | Jour | Requêtes | 4xx | 5xx | Taux 5xx |
|---|---|---:|---:|---:|---:|
| sig-app-1 | 2026-10-05 | 530 | 20 | 3 | 0.57 % |
| sig-app-1 | 2026-10-06 | 530 | 27 | 1 | 0.19 % |
| sig-app-1 | 2026-10-07 | 530 | 22 | 1 | 0.19 % |
| sig-app-1 | 2026-10-08 | 530 | 29 | 2 | 0.38 % |
| sig-app-2 | 2026-10-05 | 530 | 30 | 3 | 0.57 % |
| sig-app-2 | 2026-10-06 | 530 | 27 | 1 | 0.19 % |
| sig-app-2 | 2026-10-07 | 650 | 28 | 101 | 15.54 % |
| sig-app-2 | 2026-10-08 | 530 | 24 | 2 | 0.38 % |

L'incident saute aux yeux : 101 erreurs serveur et 120 requêtes de plus que les autres jours sur sig-app-2 le 7, les réessais des applications mobiles. Les choix qui méritent une explication :

  • Une règle par intention. Écarter la santé, rejeter les lignes mal formées, compter : trois règles, chacune avec son motif. next termine le traitement de la ligne, ce qui dispense d'imbriquer des if.
  • cle = $3 SUBSEP $1 est strictement équivalent à l'indice [$3, $1] ; l'écrire explicitement permet de réutiliser la clé dans les trois tableaux sans la recalculer.
  • Le tri par une commande. Toutes les lignes du tableau sont envoyées à un seul processus sort, puisque la chaîne tri est la même à chaque printf. -t '|' découpe sur la barre verticale : le champ 1 est vide (avant la première barre), le champ 2 est l'hôte, le champ 3 le jour. Les dates au format ISO 8601 se trient correctement comme du texte. LC_ALL=C dans la commande garantit un tri octet par octet, quel que soit l'environnement de la tâche planifiée.
  • fflush() avant le tri. L'en-tête passe par la sortie standard d'awk, les lignes par celle de sort. Sans fflush(), l'ordre final dépendrait du moment où chaque processus vide ses tampons. mawk vide de lui-même ses tampons avant de lancer une commande (c'est ce que l'on constate en retirant la ligne), mais rien dans la norme ne l'y oblige : l'appel rend l'ordre explicite au lieu de le laisser à l'implémentation.
  • close(tri) avant de sortir : awk attend la fin de sort, donc la fin de son écriture. Sans lui, awk fermerait la commande à sa sortie de toute façon, mais close renvoie aussi le code de sortie de la commande, que l'on pourrait vérifier.
  • printf(...) | tri : les parenthèses autour des arguments ne sont pas obligatoires, mais elles lèvent toute ambiguïté entre un | de redirection et une expression, et rendent la ligne lisible.
  • %.2f %% : %% affiche un signe pour cent. La locale C garantit le point décimal (on verra plus bas ce que fait une locale française).
  • Le code de sortie. exit anomalies > 0 vaut 1 si une ligne a été ignorée. Le tableau est produit quand même : un rapport partiel avec une alerte vaut mieux que pas de rapport, mais le script appelant doit le savoir. Une entrée vide donne un tableau réduit à son en-tête et le code 0 ; c'est au script appelant de vérifier qu'il a bien des journaux.

Vérifions le rejet des lignes étrangères :

$ printf 'x\ty\n' | awk -f lib/rapport.awk; echo "code $?"
rapport.awk : ligne 1 ignorée (2 champs)
| Hôte | Jour | Requêtes | 4xx | 5xx | Taux 5xx |
|---|---|---:|---:|---:|---:|
code 1

L'incident, minute par minute

Pour le compte rendu d'incident, on veut la chronologie des 503 sur sig-app-2. La clé est la date suivie de l'heure tronquée à la minute :

$ awk -f lib/requetes.awk journaux/sig-app-2*/syslog.log |
    awk -F'\t' '$6 == 503 && $5 != "/sante" { n[$1 " " substr($2, 1, 5)]++ }
        END { for (m in n) print m, n[m] }' | sort
2026-10-07 14:02 5
2026-10-07 14:03 10
2026-10-07 14:04 3
2026-10-07 14:05 3
2026-10-07 14:06 6
2026-10-07 14:07 7
2026-10-07 14:08 6
2026-10-07 14:09 6
2026-10-07 14:10 5
2026-10-07 14:11 9
2026-10-07 14:12 4
2026-10-07 14:13 7
2026-10-07 14:14 5
2026-10-07 14:15 6
2026-10-07 14:16 1
2026-10-07 14:17 8
2026-10-07 14:18 7
2026-10-07 14:19 3

Dix-huit minutes, de 14:02 à 14:19, sans interruption. Une minute sans erreur n'apparaîtrait pas du tout dans ce tableau : pour une chronologie destinée à un graphique, il faudrait générer toutes les minutes de l'intervalle et afficher 0 pour les absentes (c'est l'exercice 3).

Les tentatives SSH : dédoublonner, compter, croiser

Les lignes sshd de la forme Invalid user ubuntu from 203.0.113.77 port 54603 ont l'adresse source en huitième champ. Quelles adresses, et quand chacune est-elle apparue pour la première fois ?

$ awk '$3 ~ /^sshd\[/ && $4 == "Invalid" && !vu[$8]++ { print $1, $2, $8 }' journaux/*/syslog.log
2026-10-05T00:43:09.920500+00:00 sig-app-1 203.0.113.77
2026-10-05T01:51:00.061022+00:00 sig-app-1 203.0.113.150
2026-10-05T13:06:44.242429+00:00 sig-app-1 198.51.100.23

Trois adresses, dans leur ordre d'apparition, ce que sort -u ne donnerait pas. Le motif combine trois conditions : un processus sshd, le mot Invalid en quatrième position, et la première occurrence de l'adresse. Attention à l'ordre des conditions : !vu[$8]++ doit venir en dernier, sinon il compterait aussi les lignes qui ne sont pas des tentatives (&& n'évalue pas la suite quand une condition est fausse).

Combien de tentatives par adresse et par serveur ? Un premier jet :

$ awk '$3 ~ /^sshd\[/ && $4 == "Invalid" { n[$8, $2]++; ips[$8]; hotes[$2] }
    END { for (ip in ips) { ligne = ip
            for (h in hotes) ligne = ligne "\t" ((ip, h) in n ? n[ip, h] : 0)
            print ligne } }' journaux/*/syslog.log | sort
198.51.100.23	6	26
203.0.113.150	8	30
203.0.113.77	9	42

Le résultat est faux, et rien ne le signale. ips[$8] et hotes[$2], sans affectation, créent des clés vides qui servent d'ensembles. Mais les colonnes sont produites par for (h in hotes), dont l'ordre n'est pas garanti : mawk a parcouru ici sig-app-2 avant sig-app-1. Sans en-tête, on lirait que sig-app-2 a reçu 26 tentatives de 198.51.100.23, alors que c'est sig-app-1. Un tableau croisé exige un ordre de colonnes décidé, pas subi. En POSIX, on range les valeurs dans un tableau indexé de 1 à n, puis on le trie avec une petite fonction :

# Tri par insertion des valeurs de t[1..n], en place (POSIX).
function trier(t, n,    i, j, v) {
    for (i = 2; i <= n; i++) {
        v = t[i]
        for (j = i - 1; j >= 1 && t[j] > v; j--)
            t[j + 1] = t[j]
        t[j + 1] = v
    }
}

$3 ~ /^sshd\[/ && $4 == "Invalid" {
    n[$8, $2]++
    if (!($8 in ips))   { ips[$8];   lignes[++nl] = $8 }
    if (!($2 in hotes)) { hotes[$2]; colonnes[++nc] = $2 }
}

END {
    trier(lignes, nl)
    trier(colonnes, nc)
    printf "%-15s", "source"
    for (j = 1; j <= nc; j++) printf " %10s", colonnes[j]
    print ""
    for (i = 1; i <= nl; i++) {
        printf "%-15s", lignes[i]
        for (j = 1; j <= nc; j++) {
            k = lignes[i] SUBSEP colonnes[j]
            printf " %10d", (k in n) ? n[k] : 0
        }
        print ""
    }
}
$ awk -f croise.awk journaux/*/syslog.log
source           sig-app-1  sig-app-2
198.51.100.23           26          6
203.0.113.150           30          8
203.0.113.77            42          9
  • lignes[++nl] = $8 : un tableau à indices 1, 2, 3... sert de liste, nl en est la longueur. On n'y ajoute une adresse qu'à sa première apparition, grâce au test in.
  • trier() passe le tableau par référence : il est trié en place. i, j et v sont ses variables locales, déclarées comme paramètres supplémentaires. Le tri par insertion est quadratique, ce qui est sans importance pour quelques dizaines de valeurs ; pour des milliers, on enverrait la liste à sort.
  • t[j] > v compare des chaînes (les valeurs viennent de champs qui ne ressemblent pas à des nombres) : l'ordre est celui des octets sous LC_ALL=C. Pour des adresses IP, c'est un ordre textuel, pas numérique : 203.0.113.150 passe avant 203.0.113.77. Pour un tri numérique par octet, on passerait par sort -t. -k1,1n -k2,2n -k3,3n -k4,4n.
  • (k in n) ? n[k] : 0 évite de créer les combinaisons absentes.

sig-app-1 reçoit l'essentiel des tentatives, mais sig-app-2 en reçoit aussi : le groupe de sécurité des deux serveurs laisse le port 22 ouvert sur internet, et l'un est simplement plus visé que l'autre. Les lignes Connection closed by authenticating user root 203.0.113.77 port ... [preauth] décrivent les tentatives sur root, avec l'adresse en dixième champ :

$ awk '$3 ~ /^sshd\[/ && $7 == "authenticating" { n[$10]++ } END { for (ip in n) print n[ip], ip }' journaux/*/syslog.log | sort -rn
16 203.0.113.77
12 203.0.113.150
3 198.51.100.23

Les latences : la moyenne qui ment

Les durées de traitement ne sont que dans le journal JSON, api.jsonl. jq, à la leçon 7, saura les lire proprement. En attendant, et uniquement parce que ce journal est produit par notre propre application avec un format compact et stable, on extrait le nombre qui suit "duree_ms": avec match(), vu en leçon 5 :

$ awk 'match($0, /"duree_ms":[0-9]+/) { n++; s += substr($0, RSTART + 11, RLENGTH - 11) }
    END { printf "moyenne %.1f sur %d\n", s / n, n }' journaux/sig-app-2*/api.jsonl
moyenne 1416.8 sur 2192

RSTART est la position du début de la correspondance, RLENGTH sa longueur ; "duree_ms": compte 11 caractères, que l'on saute. Voilà la « moyenne de 1,4 seconde ». Pour les centiles, on trie les valeurs avec sort -n, puis un petit programme, centiles.awk, les charge et prend les bons rangs :

# Entrée : un nombre par ligne, déjà trié dans l'ordre croissant.
# Centile par la méthode du rang le plus proche : rang = plafond(q × n).
function rang(q, n,    r) {
    r = q * n
    return (r == int(r)) ? r : int(r) + 1
}
{ v[NR] = $1; somme += $1 }
END {
    if (NR == 0) exit 1
    printf "n=%d min=%d moy=%.1f p50=%d p95=%d p99=%d max=%d\n", NR, v[1], somme / NR,
        v[rang(0.50, NR)], v[rang(0.95, NR)], v[rang(0.99, NR)], v[NR]
}
$ for h in 1 2; do
    awk 'match($0, /"duree_ms":[0-9]+/) { print substr($0, RSTART + 11, RLENGTH - 11) }' \
        journaux/sig-app-$h*/api.jsonl | sort -n | awk -f centiles.awk
  done
n=2072 min=3 moy=35.2 p50=29 p95=80 p99=123 max=222
n=2192 min=3 moy=1416.8 p50=31 p95=144 p99=30016 max=30019

Les deux serveurs ont la même médiane, une trentaine de millisecondes. Ce qui distingue sig-app-2, c'est la queue de la distribution : un peu moins de 5 % des requêtes (les 101 réponses 503, bloquées jusqu'au délai de 30 s) suffisent à porter le p99 à 30 secondes et la moyenne à 1,4 seconde. En retirant les 503, on retrouve un serveur ordinaire :

$ awk '/"statut":503/ { next } match($0, /"duree_ms":[0-9]+/) { print substr($0, RSTART + 11, RLENGTH - 11) }' \
    journaux/sig-app-2*/api.jsonl | sort -n | awk -f centiles.awk
n=2091 min=3 moy=35.6 p50=30 p95=80 p99=119 max=363

Le rapport technique affichera donc des centiles, pas une moyenne, et le cours SLI, SLO et budgets d'erreur expliquera comment en faire des objectifs. Deux détails de centiles.awk : v[NR] = $1 range les valeurs dans l'ordre où elles arrivent, donc triées ; et exit 1 sur une entrée vide évite une division par zéro, que mawk et gawk ne traitent pas de la même façon (voir les pièges).

Le rapport de la mairie : la jointure

Il faut maintenant croiser deux sources : les exports (id,type,commune,date) et le référentiel (code,nom,population).

$ cat referentiel/communes.csv
code,nom,population
99101,Exempleville,48210
99102,"Saint-Exemple, centre",12034
99103,Val-d'Essai,8730
99104,Bourg-Témoin,3105
99105,Les Essarts-du-Test,1520

Le référentiel a le même problème que les exports : une commune entre guillemets. Avec -F,, "Saint-Exemple deviendrait le nom et centre" la population. Il faut un vrai lecteur de CSV.

Une fonction de découpage CSV en awk POSIX

# Découpe une ligne CSV dans le tableau champs (indices 1 à n) ; renvoie n,
# ou -1 si un guillemet n'est pas refermé. Les paramètres après les quatre
# espaces sont des variables locales, selon la convention d'awk.
function csv_champs(ligne, champs,    n, i, c, champ, entre_guillemets) {
    sub(/\r$/, "", ligne)
    n = 1
    champ = ""
    entre_guillemets = 0
    for (i = 1; i <= length(ligne); i++) {
        c = substr(ligne, i, 1)
        if (entre_guillemets) {
            if (c == "\"" && substr(ligne, i + 1, 1) == "\"") {
                champ = champ "\""          # "" dans un champ protégé : un guillemet
                i++
            } else if (c == "\"") {
                entre_guillemets = 0
            } else {
                champ = champ c
            }
        } else if (c == "\"") {
            entre_guillemets = 1
        } else if (c == ",") {
            champs[n++] = champ
            champ = ""
        } else {
            champ = champ c
        }
    }
    champs[n] = champ
    return entre_guillemets ? -1 : n
}

C'est un petit automate à deux états, « dans un champ ordinaire » et « entre guillemets », qui lit la ligne caractère par caractère :

  • hors guillemets, une virgule termine le champ courant et en commence un autre ; un guillemet fait entrer dans l'état protégé ; tout autre caractère est ajouté au champ ;
  • entre guillemets, deux guillemets consécutifs valent un guillemet littéral (i++ saute le second), un guillemet seul referme la protection, tout le reste (virgules comprises) est ajouté au champ ;
  • à la fin de la ligne, le champ en cours est rangé, et un guillemet resté ouvert est signalé par −1 : la ligne est tronquée, ou contient un saut de ligne dans un champ, ce que cette fonction ne gère pas ;
  • sub(/\r$/, "", ligne) retire un retour chariot final, pour les fichiers aux fins de ligne CRLF. ligne est un paramètre scalaire, donc une copie : $0 n'est pas modifié.

La fonction est plus tolérante que la RFC, qui exige que le guillemet ouvrant soit le premier caractère du champ : ab"c,d" donnerait ici un seul champ abc,d. Pour lire des exports produits par le module csv de Python, ce n'est pas un problème. Testons-la sur des cas choisis, avec un petit programme d'essai qui affiche chaque champ entre chevrons :

$ cat essai-csv.awk
{
    n = csv_champs($0, f)
    printf "%d :", n
    for (i = 1; i <= n; i++) printf " <%s>", f[i]
    print ""
}
$ printf '%s\n' '12189,graffiti,"Saint-Exemple, centre",2026-10-07' 'a,"il a dit ""non""",,c' '"x' ',' '' |
    awk -f fonction.awk -f essai-csv.awk
4 : <12189> <graffiti> <Saint-Exemple, centre> <2026-10-07>
4 : <a> <il a dit "non"> <> <c>
-1 :
2 : <> <>
1 : <>

Plusieurs -f se cumulent : fonction.awk contient la seule fonction, essai-csv.awk le programme de test. La virgule protégée, le guillemet doublé, le champ vide, le guillemet non refermé, la ligne faite d'une seule virgule (deux champs vides) et la ligne vide (un champ vide) donnent tous le résultat attendu.

communes.awk

Le programme complet, lib/communes.awk :

# communes.awk : volet « mairie » du rapport hebdomadaire de Signalements.
# Usage : awk -f lib/communes.awk referentiel/communes.csv exports/*.csv
# Le premier fichier est le référentiel (code,nom,population), les suivants
# sont des exports (id,type,commune,date). Tous ont une ligne d'en-tête.
# Champs CSV selon la RFC 4180, sans saut de ligne à l'intérieur d'un champ.
# Code de sortie : 0, ou 1 si une ligne a été ignorée ou si une commune des
# exports manque au référentiel (le tableau est produit dans les deux cas).

# Découpe une ligne CSV dans le tableau champs (indices 1 à n) ; renvoie n,
# ou -1 si un guillemet n'est pas refermé. Les paramètres après les quatre
# espaces sont des variables locales, selon la convention d'awk.
function csv_champs(ligne, champs,    n, i, c, champ, entre_guillemets) {
    sub(/\r$/, "", ligne)
    n = 1
    champ = ""
    entre_guillemets = 0
    for (i = 1; i <= length(ligne); i++) {
        c = substr(ligne, i, 1)
        if (entre_guillemets) {
            if (c == "\"" && substr(ligne, i + 1, 1) == "\"") {
                champ = champ "\""          # "" dans un champ protégé : un guillemet
                i++
            } else if (c == "\"") {
                entre_guillemets = 0
            } else {
                champ = champ c
            }
        } else if (c == "\"") {
            entre_guillemets = 1
        } else if (c == ",") {
            champs[n++] = champ
            champ = ""
        } else {
            champ = champ c
        }
    }
    champs[n] = champ
    return entre_guillemets ? -1 : n
}

FNR == 1 { next }                     # en-tête de chaque fichier

{
    n = csv_champs($0, f)
    if (n < 0) {
        printf "%s:%d : guillemet non refermé, ligne ignorée\n", FILENAME, FNR > "/dev/stderr"
        anomalies++
        next
    }
}

# Premier fichier : le référentiel des communes.
NR == FNR {
    if (n != 3 || f[3] !~ /^[0-9]+$/ || f[3] + 0 == 0) {
        printf "%s:%d : ligne de référentiel invalide\n", FILENAME, FNR > "/dev/stderr"
        anomalies++
        next
    }
    population[f[2]] = f[3]
    next
}

# Fichiers suivants : les exports.
n != 4 {
    printf "%s:%d : %d champs au lieu de 4\n", FILENAME, FNR, n > "/dev/stderr"
    anomalies++
    next
}
{
    signalements[f[3]]++
    par_type[f[3], f[2]]++
    types[f[2]] = 1
}

END {
    tri = "LC_ALL=C sort -t '|' -k4,4nr -k2,2"
    print "| Commune | Population | Signalements | Pour 1 000 hab. | Type le plus fréquent |"
    print "|---|---:|---:|---:|---|"
    fflush()
    for (commune in population) {
        meilleur = ""
        max = 0
        for (t in types) {
            if ((commune, t) in par_type) {
                k = par_type[commune, t]
                if (k > max || (k == max && t < meilleur)) {
                    max = k
                    meilleur = t
                }
            }
        }
        nb = (commune in signalements) ? signalements[commune] : 0
        printf("| %s | %d | %d | %.2f | %s |\n", commune, population[commune], nb,
            1000 * nb / population[commune], meilleur == "" ? "-" : meilleur) | tri
    }
    close(tri)
    for (commune in signalements)
        if (!(commune in population)) {
            printf "commune absente du référentiel : %s (%d signalement(s))\n",
                commune, signalements[commune] > "/dev/stderr"
            anomalies++
        }
    exit anomalies > 0
}
$ awk -f lib/communes.awk referentiel/communes.csv exports/*.csv; echo "code $?"
| Commune | Population | Signalements | Pour 1 000 hab. | Type le plus fréquent |
|---|---:|---:|---:|---|
| Exempleville | 48210 | 204 | 4.23 | depot-sauvage |
| Saint-Exemple, centre | 12034 | 52 | 4.32 | graffiti |
| Val-d'Essai | 8730 | 48 | 5.50 | nid-de-poule |
| Bourg-Témoin | 3105 | 30 | 9.66 | lampadaire |
| Les Essarts-du-Test | 1520 | 17 | 11.18 | lampadaire |
code 0

Saint-Exemple, centre a retrouvé son nom et ses 52 signalements, et la mairie découvre que les petites communes signalent, rapporté à leur population, plus que la ville-centre. On a vérifié ces chiffres de façon indépendante avec le module csv de Python : mêmes effectifs par commune, mêmes types dominants. Le programme se lit règle par règle :

  • FNR == 1 { next } saute l'en-tête de chaque fichier : FNR repart à 1 à chaque nouveau fichier, NR non.
  • Le découpage une seule fois, dans une règle sans motif, pour toutes les lignes : les règles suivantes lisent le tableau f. Une ligne illisible est comptée comme anomalie et écartée.
  • NR == FNR désigne le référentiel. La population doit être un entier strictement positif : on divisera par elle. f[3] + 0 == 0 force une comparaison numérique, f[3] étant une chaîne construite par la fonction, et non un champ lu.
  • Trois tableaux pour les exports : l'effectif par commune, l'effectif par commune et par type (clé composée), et l'ensemble des types rencontrés.
  • Le type le plus fréquent se cherche en parcourant les types ; à égalité, le plus petit dans l'ordre alphabétique gagne (t < meilleur), ce qui rend le résultat indépendant de l'ordre de parcours, et donc identique sous mawk et sous gawk. Sans ce départage, deux types à égalité donneraient un résultat qui dépend de l'implémentation.
  • Le tri : -k4,4nr trie numériquement et en ordre décroissant sur la quatrième « colonne » délimitée par | (le nombre de signalements) ; -k2,2 départage les égalités par nom de commune.
  • Les communes inconnues : un signalement dont la commune n'est pas dans le référentiel (faute de frappe, nouvelle commune associée) n'est pas perdu en silence. Il est signalé et fait échouer le programme.

Essayons un export abîmé, fabriqué pour l'occasion avec des fins de ligne CRLF, une commune inconnue, un guillemet non refermé et un champ manquant :

$ printf 'id,type,commune,date\r\n13000,graffiti,Nouvelle-Commune,2026-10-09\r\n13001,lampadaire,"Saint-Exemple, centre,2026-10-09\r\n13002,graffiti,Exempleville\r\n' > essai/export.csv
$ awk -f lib/communes.awk referentiel/communes.csv essai/export.csv; echo "code $?"
essai/export.csv:3 : guillemet non refermé, ligne ignorée
essai/export.csv:4 : 3 champs au lieu de 4
| Commune | Population | Signalements | Pour 1 000 hab. | Type le plus fréquent |
|---|---:|---:|---:|---|
| Bourg-Témoin | 3105 | 0 | 0.00 | - |
| Exempleville | 48210 | 0 | 0.00 | - |
| Les Essarts-du-Test | 1520 | 0 | 0.00 | - |
| Saint-Exemple, centre | 12034 | 0 | 0.00 | - |
| Val-d'Essai | 8730 | 0 | 0.00 | - |
commune absente du référentiel : Nouvelle-Commune (1 signalement(s))
code 1

Chaque défaut est nommé avec son fichier et son numéro de ligne, au format fichier:ligne que reconnaissent les éditeurs. Les \r n'ont gêné personne. Et le code 1 permettra au script du rapport de refuser d'envoyer à la mairie un tableau dont des lignes ont disparu.

Le cas limite de NR == FNR

Que se passe-t-il si le premier fichier est vide, par exemple parce qu'une copie du référentiel a échoué ?

$ : > vide.csv
$ awk 'NR == FNR { ref[$1]; next } { n++ } END { print length(ref) " clés de référence, " n+0 " lignes de données" }' vide.csv exports/signalements-2026-10-05.csv
97 clés de référence, 0 lignes de données
$ awk 'FILENAME == ARGV[1] { ref[$1]; next } { n++ } END { print length(ref) " clés de référence, " n+0 " lignes de données" }' vide.csv exports/signalements-2026-10-05.csv
0 clés de référence, 97 lignes de données

Avec un premier fichier vide, NR et FNR avancent ensemble pendant tout le second : l'export est pris pour le référentiel. FILENAME == ARGV[1] compare le nom du fichier courant au premier argument, et ne se trompe pas. Dans communes.awk, on garde NR == FNR, l'idiome que tout lecteur d'awk reconnaît, parce que l'erreur y est bruyante : chaque ligne d'export, lue comme référentiel, a quatre champs au lieu de trois et déclenche une anomalie, puis le code 1. Si le programme avait été silencieux, FILENAME == ARGV[1] se serait imposé. (Il a lui-même un défaut : ARGV[1] peut être une affectation de variable nom=valeur placée avant les fichiers.)

Lire un fichier annexe avec getline

Dans communes.awk, le référentiel passe par l'entrée principale. On aurait pu le charger dans BEGIN avec getline. Sur un fichier qui n'existe pas, la forme courte boucle sans fin ; on borne ici l'essai à cinq tours :

$ awk 'BEGIN { while (getline ligne < "referentiel/absent.csv") { if (++tours > 5) {
      print "toujours là après 5 tours, valeur de retour :", (getline ligne < "referentiel/absent.csv"); exit } } }'
toujours là après 5 tours, valeur de retour : -1
$ awk 'BEGIN { while ((r = (getline ligne < "referentiel/absent.csv")) > 0) n++; print "retour", r, "lignes", n + 0 }'
retour -1 lignes 0
$ awk 'BEGIN { while ((getline ligne < "referentiel/communes.csv") > 0) n++; close("referentiel/communes.csv"); print n " lignes" }'
6 lignes

Retenez la forme (getline var < fichier) > 0, et testez séparément −1 pour signaler l'erreur. Le close() final rend le fichier, et permettrait de le relire depuis le début.

La forme commande | getline récupère une valeur calculée à l'extérieur, par exemple le numéro de semaine ISO pour le titre du rapport :

$ awk 'BEGIN { cmd = "date -u -d 2026-10-09 +%G-S%V"; cmd | getline semaine; close(cmd); print "semaine " semaine }'
semaine 2026-S41

La commande est passée à /bin/sh : tout ce qui entre dans sa chaîne doit être maîtrisé, on y revient dans la partie Sécurité.

Dernier piège, la forme nue, qui consomme une ligne de l'entrée principale et modifie NR et FNR :

$ awk 'FNR == 1 { getline; print FILENAME, FNR, NR, $0 }' exports/signalements-2026-10-05.csv exports/signalements-2026-10-06.csv
exports/signalements-2026-10-05.csv 2 2 12000,signalisation,Exempleville,2026-10-05
exports/signalements-2026-10-06.csv 2 99 12096,signalisation,Bourg-Témoin,2026-10-06

Sur la première ligne de chaque fichier, getline a lu la deuxième : les règles suivantes ne verront jamais la ligne d'en-tête, mais elles ne verront pas non plus la deuxième ligne passer par le début du programme. Ce genre de saut rend un programme difficile à raisonner ; FNR == 1 { next } dit la même chose plus clairement.

Écrire plusieurs fichiers, et les fermer

Pour archiver les requêtes par hôte et par heure, un fichier chacun, une ligne suffit : print > ("parts/" $3 "-" $1 "T" substr($2, 1, 2) ".tsv"). Les parenthèses autour du nom sont indispensables : sans elles, dans print > "parts/" $3, la norme ne garantit pas que la concaténation fasse partie du nom de fichier, et les implémentations ne s'accordent pas. Mais quatre jours, deux hôtes et vingt-quatre heures font 192 fichiers, tous ouverts en même temps. Avec une limite basse de descripteurs, que l'on fixe ici à 32 pour la démonstration :

$ mkdir -p parts
$ ulimit -n 32; awk -F'\t' '{ print > ("parts/" $3 "-" $1 "T" substr($2, 1, 2) ".tsv") }' requetes.tsv; echo "code $?"
awk: cannot open "parts/sig-app-1-2026-10-06T04.tsv" for output (Too many open files)
code 2

La limite usuelle est de 1 024 descripteurs par processus, qu'un découpage par client ou par chemin atteint facilement. Notez aussi que l'échec n'a rien annulé : une trentaine de fichiers partiels sont restés dans parts/. La correction : fermer le fichier précédent quand on change de fichier, et écrire en ajout, dans un répertoire vidé au préalable :

$ rm -f parts/*.tsv
$ ulimit -n 32; awk -F'\t' '{ f = "parts/" $3 "-" $1 "T" substr($2, 1, 2) ".tsv"
      if (f != precedent) { if (precedent != "") close(precedent); precedent = f }
      print >> f }' requetes.tsv; echo "code $?"
code 0
$ ls parts | wc -l; cat parts/*.tsv | wc -l
192
15880

Pourquoi >> ? Parce qu'après un close(), un nouveau print > rouvre et vide le fichier. Démonstration sur trois lignes dont les clés alternent :

$ printf 'a 1\nb 2\na 3\n' | awk '{ f = "p2/" $1; print $2 > f; close(f) }'; head p2/*
==> p2/a <==
3

==> p2/b <==
2

La ligne a 1 a disparu, écrasée par la réouverture. Avec >>, il faut en contrepartie partir d'un répertoire vide, sinon une deuxième exécution ajoute aux fichiers de la première. Ici, l'entrée est triée par hôte et par heure, si bien que chaque fichier n'est ouvert qu'une fois ; sur une entrée non triée, la version avec close rouvrirait souvent les mêmes fichiers, ce qui reste correct mais coûte des appels système.

Assembler les deux volets

La leçon 8 écrira rapport-hebdo avec sa gestion d'erreurs complète. Pour vérifier dès maintenant que les deux programmes s'emboîtent :

set -o pipefail
{
    printf '# Signalements : semaine du 5 au 8 octobre 2026\n\n## Signalements par commune\n\n'
    awk -f lib/communes.awk referentiel/communes.csv exports/*.csv
    printf "\n## Trafic de l'API\n\n"
    awk -f lib/requetes.awk journaux/*/syslog.log | awk -f lib/rapport.awk
} > rapport.md

Le fichier produit compte 24 lignes, deux tableaux Markdown que la mairie peut lire tels quels, ou convertir. pipefail fait remonter un échec de requetes.awk ; la leçon 8 vérifiera aussi celui de communes.awk, à l'intérieur du bloc.

Mesurer

Pour comparer les méthodes sur un volume réaliste, on a généré un bac à sable avec --volume 10 et concaténé vingt fois ses deux syslog.log : 1 095 660 lignes, 143 Mo. Mesures faites avec time sur un poste récent, en répétant chaque commande trois fois (les valeurs varient de quelques centièmes) :

MéthodeDurée
awk '... { print $2, $12 }' | sort | uniq -c sous C.UTF-80,50 à 0,55 s
la même sous LC_ALL=C0,44 à 0,46 s
awk '... { n[$2 " " $12]++ } END { ... }'0,22 à 0,24 s

Agréger dans awk est deux fois plus rapide que de passer par sort | uniq -c, parce qu'on évite de trier un million de lignes pour n'en garder que onze. Le tri reste indispensable quand on veut un ordre ou un centile ; il est superflu pour un simple décompte.

La mémoire, elle, suit le nombre de clés distinctes, pas le nombre de lignes. Mesuré avec /usr/bin/time -f "%e s, %M Kio" sur le même fichier :

CléClés distinctesMémoire maximale
hôte et code112 476 Kio
chemin (pièces jointes comprises)6 2723 100 Kio
horodatage complet54 2568 788 Kio

Quelques méga-octets pour des dizaines de milliers de clés : awk tient sans peine des millions de lignes, tant que la clé ne prend pas une valeur différente sur presque chaque ligne. Une clé comme l'identifiant de requête ou l'horodatage à la microseconde, sur des journaux de plusieurs gigaoctets, finirait par occuper autant de mémoire que les données.

Sous le capot

Une table de hachage

Un tableau d'awk est une table de hachage (hash table) : la clé est passée dans une fonction de hachage qui donne un numéro de case, et l'élément est rangé dans cette case (ou dans une liste chaînée attachée, en cas de collision). Recherche, insertion et suppression se font en temps à peu près constant, quelle que soit la taille du tableau. C'est ce qui rend n[$3]++ aussi rapide sur la millionième ligne que sur la première.

C'est aussi l'origine de l'ordre de for (k in t) : il suit les cases, donc les valeurs de hachage, et non l'ordre d'insertion ni l'ordre alphabétique. Le même jeu de clés, inséré dans mawk et dans gawk, sort dans deux ordres différents, et l'ajout d'une clé peut réorganiser la table. Un essai sur les jours de la semaine :

$ awk 'BEGIN { split("lun mar mer jeu ven sam dim", j, " "); for (i = 1; i <= 7; i++) n[j[i]] = i
    for (k in n) printf "%s ", k; print "" }'
jeu mar mer sam lun ven dim

La documentation interne de mawk (la page de manuel mawk-arrays(7), tirée de l'ancien fichier source array.w) décrit une organisation hybride : un tableau rempli par split() est stocké comme un vrai vecteur, et converti en table de hachage, indexée par des entiers ou par des chaînes, dès qu'on l'utilise autrement. gawk a lui aussi plusieurs représentations internes selon la nature des indices. Pour le programmeur, la conséquence est la même : ne jamais dépendre de l'ordre de parcours. Soit on trie (commande sort, fonction de tri, PROCINFO["sorted_in"] en gawk), soit on parcourt une liste indexée 1..n que l'on a construite.

Une clé composée, vue de près

$ awk 'BEGIN { t["sig-app-2", "2026-10-07"] = 650; for (k in t) print k | "od -c | head -2"; close("od -c | head -2")
    if (("sig-app-2", "2026-10-07") in t) print "présente"
    if (("sig-app-2" SUBSEP "2026-10-07") in t) print "même clé" }'
0000000   s   i   g   -   a   p   p   -   2 034   2   0   2   6   -   1
0000020   0   -   0   7  \n
présente
même clé

od -c montre l'octet 034 entre les deux composantes : la clé « à deux dimensions » est une chaîne ordinaire. Il en découle un risque de collision : si une composante contient elle-même \034, deux couples différents peuvent produire la même chaîne. Avec du texte ordinaire, cela n'arrive pas ; avec des données binaires ou hostiles, c'est possible, et l'on choisit alors un SUBSEP impossible dans les données, ou l'on valide les composantes.

Un nombre est un double

awk ne connaît qu'un type numérique, le nombre à virgule flottante en double précision de la norme IEEE 754 (mawk et gawk sans l'option -M de précision arbitraire). Les entiers sont exacts jusqu'à 2⁵³, soit 9 007 199 254 740 992 :

$ awk 'BEGIN { printf "%d %d\n", 2^53, 2^53 + 1; print 0.1 + 0.2 }'
9007199254740992 9007199254740992
0.3

2⁵³ + 1 n'est pas représentable et vaut 2⁵³. Pour compter des requêtes ou additionner des octets, la marge est immense. Pour des identifiants numériques longs (certains identifiants de transaction, des numéros de série sur 64 bits), il ne faut jamais les traiter comme des nombres : utilisez-les comme clés textuelles, sans calcul, ou le dernier chiffre risque de changer. Quant à 0.1 + 0.2, il s'affiche 0.3 parce que print formate les nombres avec OFMT, qui vaut %.6g ; la valeur exacte est 0,30000000000000004, et 0.1 + 0.2 == 0.3 est faux. Pour comparer des décimaux, comparez des entiers (des millisecondes plutôt que des secondes) ou tolérez un écart.

Ce que font close et getline sous le capot

print | "commande" crée un tube, lance /bin/sh -c "commande" dans un processus enfant dont l'entrée standard est l'extrémité de lecture, et garde l'extrémité d'écriture. close("commande") ferme cette extrémité, ce qui envoie la fin de fichier à l'enfant, puis attend sa terminaison avec waitpid. C'est pourquoi close peut renvoyer le code de sortie de la commande, et c'est ce que font mawk et gawk :

$ awk 'BEGIN { print "x" | "cat > /dev/null; exit 3"; r = close("cat > /dev/null; exit 3"); print "close (sortie) :", r
    "exit 4" | getline v; r = close("exit 4"); print "close (entrée) :", r
    r = system("exit 5"); print "system :", r
    r = close("jamais-ouvert"); print "close inconnu :", r }'
close (sortie) : 3
close (entrée) : 4
system : 5
close inconnu : -1

POSIX garantit seulement « zéro en cas de succès, autre chose sinon » ; la valeur exacte du code est une convention de ces deux implémentations, documentée par le manuel de gawk. Dans rapport.awk, on pourrait écrire if (close(tri) != 0) pour détecter un sort qui échoue (disque de fichiers temporaires plein, par exemple).

commande | getline fait l'inverse : l'enfant écrit dans le tube, awk lit. Tant que la chaîne n'est pas fermée, les appels suivants lisent la suite de la même sortie ; c'est pourquoi on ferme après avoir lu, si l'on veut relancer la commande plus tard.

Pièges courants

for (k in t) et l'ordre. L'ordre de parcours est non spécifié et change d'une implémentation à l'autre. Un tableau croisé dont les colonnes sont produites par for ... in peut avoir ses colonnes inversées sans le moindre message. Triez, ou parcourez une liste indexée.

Tester une clé en la lisant. if (t[k] == "") ou if (t[k] > 0) crée t[k]. Utilisez if (k in t) ; pour une clé composée, if ((a, b) in t), parenthèses comprises.

Le minimum qui reste vide. if ($x < min) min = $x ne fait rien quand les valeurs sont positives :

$ printf "42\n17\n30\n" | awk '{ if ($1 < min) min = $1 } END { print "min <" min ">" }'
min <>
$ printf "42\n17\n30\n" | awk 'NR == 1 || $1 < min { min = $1 } END { print "min <" min ">" }'
min <17>

Le maximum textuel. Une valeur extraite par substr(), split() ou une fonction est une chaîne, pas une chaîne numérique : elle se compare comme du texte, et "9" > "10".

$ printf "9\n10\n" | awk '{ if ($1 > m) m = $1 } END { print "max", m }'
max 10
$ printf "9\n10\n" | awk '{ v = substr($0, 1); if (v > m) m = v } END { print "max", m }'
max 9

Ajoutez + 0 pour forcer un nombre : v = substr($0, 1) + 0.

print n > 10. Dans une instruction print, > est une redirection : cette ligne crée un fichier nommé 10.

$ awk '{ n++ } END { print n > 10 }' requetes.tsv; cat 10
15880

Pour afficher une comparaison, mettez-la entre parenthèses : print (n > 10).

while (getline ligne < f). Boucle infinie si f n'existe pas, puisque −1 est vrai. while ((getline ligne < f) > 0).

Trop de fichiers ouverts. Chaque nom distinct dans print > nom garde un descripteur jusqu'à la fin. close() quand on change de fichier, et >> pour ne pas écraser à la réouverture.

NR == FNR et le premier fichier vide. Le second fichier est pris pour le premier. Vérifiez que le fichier de référence n'est pas vide avant d'appeler awk, ou utilisez FILENAME == ARGV[1].

-F, sur du CSV. Il découpe sur chaque virgule, guillemets compris. Fonction de découpage, --csv de gawk 5.3 et plus, ou un autre outil.

Les décimales sous une locale française. mawk applique la locale aux nombres, en entrée comme en sortie :

$ LC_ALL=fr_FR.UTF-8 awk 'BEGIN { printf "%.2f\n", 1000 * 204 / 48210; x = "4,5"; print x + 1 }'
4,23
5,5

Le taux s'écrit avec une virgule, et "4,5" est lu comme quatre et demi. Pour une personne, c'est peut-être souhaitable ; pour un fichier qu'un autre programme relira, c'est un format qui change selon la machine. Les tâches planifiées fixent LC_ALL=C.UTF-8 (ou C), et les programmes qui doivent produire un point décimal ne laissent pas ce choix à l'environnement.

Le dédoublonnage placé au mauvais endroit. Dans cond && !vu[$k]++, le dédoublonnage ne s'applique qu'aux lignes qui satisfont cond. Écrit !vu[$k]++ && cond, il marquerait comme « vue » une clé apparue d'abord sur une ligne écartée.

Une division par zéro. Elle ne se comporte pas de la même façon partout. mawk ne dit rien et continue avec une valeur infinie :

$ awk 'BEGIN { print 1/0 }'; echo "code $?"
inf
code 0

gawk, lui, s'arrête sur une erreur fatale (division by zero attempted, d'après son manuel). Un rapport peut donc afficher inf sur sig-outils et échouer sur sig-app-1. Testez l'effectif avant de diviser, ou sortez tôt avec un code d'erreur comme centiles.awk.

Sécurité

  • Les données décident des noms de fichiers. print > ("parts/" $3 ".tsv") écrit là où le troisième champ le décide. Si ce champ vient d'une source non maîtrisée (un nom d'hôte déclaré par le client, un chemin d'URL), une valeur comme ../../.ssh/authorized_keys sort du répertoire prévu. Validez la composante avant de l'utiliser dans un chemin, par exemple $3 ~ /^[a-z0-9-]+$/, et écartez le reste en le signalant.

  • Les commandes construites avec des données. cmd | getline, print | cmd et system(cmd) passent cmd à /bin/sh. Une valeur qui contient ;, $( ) ou une apostrophe devient du code. Démonstration avec un nom d'hôte piégé, passé proprement par -v, mais recollé dans une commande :

    $ x="sig-app-1; echo PIRATÉ >&2"
    $ awk -v h="$x" 'BEGIN { cmd = "getent hosts " h; while ((cmd | getline l) > 0) print l; close(cmd) }'
    PIRATÉ
    

    -v protège le programme awk, pas les commandes qu'il construit. Dans un programme d'agrégation, la meilleure règle est de ne lancer aucune commande dont la chaîne contient des données : les seules commandes de cette leçon (sort, date) sont des chaînes fixes. Si c'est inévitable, validez la donnée par une liste blanche stricte.

  • Les données personnelles dans les rapports. Le volet technique contient les adresses des machines qui attaquent SSH : ce sont des données de sécurité légitimes, à conserver dans le cadre prévu par le registre des traitements. Il ne doit pas contenir les adresses des usagers, que l'on trouve dans api.jsonl : le rapport agrège, il ne recopie pas. Le volet mairie ne contient que des effectifs par commune. Attention cependant aux petits effectifs : dans une commune de 1 520 habitants, « un signalement de dépôt sauvage rue des Lilas » peut désigner une personne. Le RGPD s'applique à des données indirectement identifiantes ; les statisticiens publics fixent un seuil de diffusion sous lequel ils masquent les cases. Le rapport de Signalements reste à la maille de la commune et du type, jamais de l'adresse.

  • L'épuisement de la mémoire par des clés hostiles. Compter par chemin d'URL semble anodin. Mais les chemins sont choisis par les clients : un scanner qui demande cent mille URL différentes crée cent mille clés. Sur des données exposées à internet, agrégez sur des clés bornées (code HTTP, méthode, préfixe de chemin normalisé) plutôt que sur des valeurs libres, ou limitez le nombre de clés et regroupez le reste sous « autres ».

  • L'injection dans le tableau produit. Le Markdown est affiché par la mairie, éventuellement converti en tableur. Un nom de commune contenant | casserait le tableau ; un nom commençant par =, +, - ou @, ouvert dans un tableur, peut être interprété comme une formule, ce que l'OWASP appelle l'injection CSV. Ici, les noms viennent du référentiel, maintenu par l'équipe, et non des exports : c'est une raison de plus pour n'afficher que les communes du référentiel, et de signaler, sans les recopier dans le tableau, les communes inconnues.

En production

  • Des programmes dans des fichiers, versionnés et testés. lib/rapport.awk et lib/communes.awk vivent dans le dépôt signalements-outils, avec des jeux d'essai (l'export abîmé ci-dessus en est un) et des tests Bats comme ceux de la leçon 12 du cours Bash, qui comparent la sortie à un résultat attendu. Un programme awk de quarante lignes collé dans un script shell entre apostrophes est illisible, impossible à tester et pénible à citer.
  • Écrire pour POSIX, tester sur les deux implémentations. Les tâches tournent sur sig-outils (mawk), mais un collègue lancera le programme sur sig-app-1 (gawk) pendant une enquête. La CI peut l'exécuter avec les deux : mawk -f et gawk --posix -f. L'option --posix de gawk désactive ses extensions et révèle celles que l'on a utilisées par mégarde.
  • Un résultat reproductible. Tri explicite, départage des égalités, locale fixée : deux exécutions sur les mêmes données doivent produire les mêmes octets. C'est ce qui permet de comparer deux rapports avec diff, et de détecter une régression dans un test.
  • Des codes de sortie qui veulent dire quelque chose. Les deux programmes produisent leur tableau même en cas d'anomalie, mais sortent avec le code 1. Le script appelant décide : envoyer quand même avec un avertissement, ou bloquer l'envoi à la mairie.
  • Savoir quand changer d'outil. awk excelle tant que les données sont tabulaires, que la logique tient en quelques dizaines de lignes et que l'état tient en mémoire. Au-delà : pour du CSV général (sauts de ligne dans les champs, encodages variés), Miller (mlr, version 6.13 dans Debian 13) lit et écrit CSV, TSV et JSON avec des verbes comme count-distinct ou join ; pour une logique plus riche, Python et son module csv ; pour des jointures multiples, des fenêtres temporelles ou des centiles sur des millions de lignes, une base de données embarquée comme SQLite ou DuckDB, qui lisent directement un CSV et répondent en SQL. La leçon 8 dresse le tableau de décision complet.
  • Surveiller le rapport lui-même. Un rapport hebdomadaire dont le nombre de requêtes chute de moitié, ou dont une commune passe à zéro, signale peut-être un problème de collecte (journaux non reçus, export vide) plutôt qu'une baisse réelle. Comparer quelques totaux d'une semaine à l'autre est un contrôle de cohérence peu coûteux.

Exercices

1. Prévoir sans exécuter (niveau 100). Pour chacun de ces programmes, appliqué à requetes.tsv avec -F'\t', dites ce qu'il affiche ou fait, puis vérifiez. (a) { n[$6]++ } END { for (c in n) print c, n[c] } ; (b) !vu[$3]++ { print $3 } ; (c) END { print length(t) } après la règle { if (t[$3] == "x") print } ; (d) END { print NR > 15000 }.

Solution

(a) L'effectif de chaque code HTTP (200, 201, 304, 404, 500, 503), dans un ordre non garanti : ajoutez | sort pour un ordre stable. (b) sig-app-1 puis sig-app-2 : la première occurrence de chaque hôte, dans l'ordre d'apparition. (c) 2 : la comparaison t[$3] == "x" n'est jamais vraie, mais elle crée une clé par hôte rencontré. (d) Rien à l'écran : > est une redirection, et awk écrit 15880 dans un fichier nommé 15000. Pour afficher la comparaison : print (NR > 15000), qui affiche 1.

2. Le volume par méthode et par jour (niveau 100). À partir de requetes.tsv, affichez pour chaque jour le nombre de GET et de POST d'usagers (sans /sante), une ligne par jour, triée.

Solution
awk -F'\t' '$5 != "/sante" { n[$1, $4]++; jours[$1] }
    END { for (j in jours) printf "%s GET=%d POST=%d\n", j, n[j, "GET"], n[j, "POST"] }' requetes.tsv | sort

jours[$1] sert d'ensemble. n[j, "POST"] crée la clé si elle n'existe pas, ce qui est sans conséquence ici puisque le tableau n'est plus parcouru ; pour être irréprochable, écrivez ((j, "POST") in n ? n[j, "POST"] : 0). Une variante plus générale collecterait aussi les méthodes dans un ensemble et produirait une colonne par méthode, triées comme dans croise.awk.

3. Une chronologie sans trou (niveau 200). Le décompte des 503 par minute omet les minutes sans erreur. Modifiez-le pour afficher toutes les minutes de 13:55 à 14:25 le 7 octobre sur sig-app-2, avec 0 quand il n'y a pas eu d'erreur, sans utiliser de commande externe pour générer les minutes.

Solution
awk -f lib/requetes.awk journaux/sig-app-2*/syslog.log |
awk -F'\t' '$1 == "2026-10-07" && $6 == 503 && $5 != "/sante" { n[substr($2, 1, 5)]++ }
    END {
        for (m = 13 * 60 + 55; m <= 14 * 60 + 25; m++) {
            cle = sprintf("%02d:%02d", int(m / 60), m % 60)
            printf "%s %d\n", cle, (cle in n) ? n[cle] : 0
        }
    }'

On parcourt les minutes comme des nombres (minutes depuis minuit), que l'on reconvertit en clé HH:MM avec sprintf : la boucle produit elle-même l'ordre, aucun tri n'est nécessaire. Le format %02d garantit que la clé construite est identique à celle extraite du journal (14:05, pas 14:5). Les lignes de 13:55 à 14:01 et de 14:20 à 14:25 affichent 0.

4. Le top 3 des chemins en erreur (niveau 200). Listez, pour l'ensemble de la semaine, les trois chemins (sans chaîne de requête, et en regroupant /signalements/<id> et /pieces-jointes/<fichier> sous une forme générique) qui ont reçu le plus de réponses 5xx, avec leur nombre. Pourquoi faut-il normaliser les chemins avant de compter, du point de vue des performances comme de la sécurité ?

Solution
awk -F'\t' '$6 >= 500 {
        c = $5
        sub(/\?.*/, "", c)                              # retire la chaîne de requête
        sub(/^\/signalements\/[0-9]+$/, "/signalements/:id", c)
        sub(/^\/pieces-jointes\/.*/, "/pieces-jointes/:fichier", c)
        n[c]++
    }
    END { for (c in n) print n[c], c }' requetes.tsv | sort -rn | head -3

Sans normalisation, chaque identifiant de signalement et chaque nom de pièce jointe serait une clé distincte : le classement serait dominé par le bruit, avec des effectifs de 1, et le tableau grossirait avec le nombre de valeurs différentes. Comme les chemins sont choisis par les clients, un scanner pourrait créer autant de clés qu'il envoie de requêtes : regrouper sur une forme générique borne le nombre de clés, donc la mémoire, et rend le résultat lisible.

5. La jointure inversée (niveau 200). La mairie voudrait aussi la liste des signalements de la semaine dont la commune n'est pas dans le référentiel, avec leur fichier et leur numéro de ligne, pour corriger la saisie. Écrivez inconnues.awk, réutilisant csv_champs() (que vous chargerez par un second -f), et testez-le sur essai/export.csv.

Solution

Placez la fonction seule dans lib/csv.awk, puis :

# inconnues.awk : signalements dont la commune manque au référentiel.
# Usage : awk -f lib/csv.awk -f inconnues.awk referentiel/communes.csv exports/*.csv
FNR == 1 { next }
{ n = csv_champs($0, f) }
NR == FNR { if (n == 3) connue[f[2]]; next }
n == 4 && !(f[3] in connue) { printf "%s:%d\t%s\t%s\n", FILENAME, FNR, f[1], f[3]; trouve++ }
END { exit trouve > 0 }

Sur essai/export.csv, il affiche essai/export.csv:2, l'identifiant 13000 et Nouvelle-Commune, et sort avec le code 1. Les lignes mal formées (guillemet non refermé, n vaut −1, ou trois champs) ne sont pas listées : c'est le rôle de communes.awk de les signaler. Extraire csv_champs() dans un fichier à part évite de la recopier : communes.awk pourrait faire de même, au prix d'un second -f à chaque appel.

6. Portabilité (niveau 200). Un collègue propose cette version plus courte du rapport par commune, testée sur sig-app-1 :

gawk --csv 'FNR > 1 && NR != FNR { n[$3]++ } NR == FNR && FNR > 1 { p[$2] = $3 }
    END { PROCINFO["sorted_in"] = "@val_num_desc"; for (c in n) printf "%s %d %.2f\n", c, n[c], 1000 * n[c] / p[c] }' \
    referentiel/communes.csv exports/*.csv

Elle échoue sur sig-app-1 lui-même. Pourquoi ? Que se passerait-il sur sig-outils ? Quelles parties sont des extensions de gawk, et par quoi les remplacer en POSIX ?

Solution

--csv n'existe qu'à partir de gawk 5.3 ; Ubuntu 24.04 livre gawk 5.2.1, qui rejette l'option inconnue. Sur sig-outils, gawk n'est même pas installé (Debian minimale, awk est mawk) : command not found. Les extensions sont --csv (remplacée par csv_champs()) et PROCINFO["sorted_in"] (remplacé par l'envoi des lignes à sort -t '|' -k...nr ou par une fonction de tri). Le programme a aussi un défaut indépendant des extensions : une commune absente du référentiel donne une division par zéro, p[c] valant 0, ce qui arrête gawk sur une erreur fatale (et ferait afficher inf à mawk). C'est précisément ce que communes.awk signale proprement.

Récapitulatif

  • Le tableau associatif est la seule structure de données d'awk : clés textuelles, création à la première utilisation, valeur initiale 0 et chaîne vide. n[k]++, s[k] += x et t[k] = $0 couvrent presque toutes les agrégations.
  • (k in t) teste sans créer ; toute autre lecture crée l'élément. delete t[k], delete t et length(t) sont dans POSIX.1-2024.
  • for (k in t) parcourt dans un ordre non spécifié, différent entre mawk et gawk : triez avec sort, une fonction, ou parcourez une liste indexée construite à part.
  • Une clé composée t[a, b] est une chaîne a SUBSEP b ; on la redécoupe avec split(k, p, SUBSEP).
  • Minimum : initialiser avec la première valeur. Moyenne : une somme et un effectif. Centiles : trier (sort -n) puis prendre le rang ⌈q × n⌉. Une moyenne de temps de réponse cache la queue de la distribution.
  • !vu[$k]++ dédoublonne en gardant l'ordre d'origine, au prix d'une clé en mémoire par valeur distincte.
  • Jointure : NR == FNR { ...; next } charge le premier fichier ; attention au premier fichier vide (FILENAME == ARGV[1]).
  • getline renvoie 1, 0 ou −1 : toujours (getline var < f) > 0. cmd | getline et print | cmd passent par /bin/sh : jamais de données dans cmd.
  • Redirections identifiées par leur chaîne, ouvertes jusqu'à close() ; close attend la commande et renvoie son code ; fflush() ordonne les sorties ; >> après un close pour ne pas écraser.
  • Fonctions : variables locales déclarées comme paramètres supplémentaires, tableaux passés par référence.
  • Le CSV demande un automate (csv_champs()), FPAT ou --csv (gawk 5.3 et plus, absent de Debian 13 et d'Ubuntu 24.04), ou un autre outil.
  • Pour un rapport reproductible : POSIX, tri explicite, départage des égalités, LC_ALL=C, codes de sortie qui signalent les anomalies.

Pour aller plus loin

  • La spécification POSIX d'awk, courte et précise sur les tableaux, getline, les redirections et les ajouts de 2024 (fflush, delete d'un tableau entier, length d'un tableau).
  • Le manuel de gawk, GAWK: Effective AWK Programming, en particulier le chapitre Arrays in awk, la page Points About getline to Remember, et les sections sur FPAT et --csv, utiles même si l'on écrit pour mawk, pour savoir ce que l'on s'interdit.
  • The AWK Programming Language, 2e édition (2023), d'Aho, Kernighan et Weinberger : les chapitres sur les rapports et sur la manipulation de données relationnelles montrent jusqu'où l'on peut aller avec quelques dizaines de lignes, et où il vaut mieux s'arrêter.
  • La RFC 4180, à relire avec la fonction csv_champs() sous les yeux, et la documentation de Miller pour le jour où le CSV devient vraiment compliqué.
  • La leçon suivante, jq : interroger du JSON, lit enfin api.jsonl comme il se doit, et remplace l'extraction fragile des durées par match().
+20 XP Carte du ciel →Mon cosmonaute →

Sources