jq : transformer, produire du JSON et choisir son outil
Pourquoi
La leçon 7 a appris à lire du JSON : extraire un champ, filtrer des objets, sortir du texte pour awk. C'était la moitié du travail. Le jeudi 8 octobre, l'équipe doit aussi en produire : la mairie veut le rapport hebdomadaire sous forme de tableau, mais le service informatique de la mairie demande en plus un fichier JSON qu'il importera dans son propre outil de suivi ; l'équipe veut qu'une alerte parte sur sa messagerie quand le taux d'erreurs dépasse un seuil ; et le fichier de seuils, alertes.json, doit pouvoir être modifié par un script.
Camille avait déjà essayé. Son script d'alerte fabriquait le message ainsi :
printf '{"text": "Alerte sur %s : %s"}' "$hote" "$message" | curl -d @- "$WEBHOOK"Il a fonctionné des mois, jusqu'au jour où le message contenait l'erreur PostgreSQL de l'incident, connection to server at "sig-db" failed : des guillemets doubles au milieu d'une chaîne JSON. Le serveur de messagerie a répondu par une erreur 400, que personne ne lisait, et l'alerte n'est jamais arrivée. Le même jour, un collègue qui voulait relever un seuil a tapé jq '.seuils.p95_ms = 300' alertes.json > alertes.json : le fichier s'est retrouvé vide, et le script de surveillance s'est arrêté sur une erreur d'analyse.
Ces deux incidents ont la même racine : traiter du JSON comme du texte. Une chaîne JSON a ses règles d'échappement (RFC 8259), un document JSON doit être complet pour être valide, et le shell ne connaît ni l'un ni l'autre. Cette leçon apprend à faire produire le JSON par jq, à partir de n'importe quelle source (JSON, texte brut, variables), à le transformer et à le réécrire sans le casser. Elle se termine par l'assemblage de rapport-hebdo, qui combine les outils des huit leçons, et par la question qui conclut le cours : pour une tâche donnée, quel outil choisir, et quand n'en choisir aucun de ceux-là.
Les concepts
Construire, c'est encore filtrer
Dans jq, tout est filtre (leçon 7) : une expression reçoit une entrée et produit zéro, une ou plusieurs sorties. Construire un objet ou un tableau ne fait pas exception.
[ f ]construit un tableau qui collecte toutes les sorties def.[.[] | .name]rassemble les noms en un seul tableau ;[inputs]rassemble tous les documents d'un flux.{cle: f}construit un objet. Plusieurs raccourcis :{name}vaut{name: .name};{(.name): .state}calcule la clé (les parenthèses sont obligatoires) ;{"clé avec espace": 1}accepte une chaîne comme clé ;{$hote}vaut{hote: $hote}.
Une subtilité découle du modèle de flux : si la valeur d'une clé produit plusieurs sorties, l'objet est produit autant de fois, et avec plusieurs clés de ce genre, on obtient toutes les combinaisons (le produit cartésien). C'est rarement voulu, mais c'est la clé de beaucoup de résultats surprenants : une valeur qui produit plusieurs sorties se met entre crochets.
Regrouper, trier, dédoublonner
Ces fonctions s'appliquent à un tableau et en renvoient un autre :
| Fonction | Effet |
|---|---|
sort, sort_by(f) | trie, selon la valeur ou selon f ; l'ordre de jq est total : null, false, true, nombres, chaînes, tableaux, objets |
group_by(f) | trie selon f puis découpe en sous-tableaux de même valeur de f |
unique, unique_by(f) | trie et garde un élément par valeur |
min_by(f), max_by(f), min, max | l'élément extrême |
add | additionne les éléments : somme de nombres, concaténation de chaînes ou de tableaux, fusion d'objets |
any(gen; cond), all(gen; cond) | existe-t-il un élément, tous les éléments vérifient-ils cond |
length | taille d'un tableau, d'un objet ou d'une chaîne |
group_by est l'équivalent de sort | uniq -c de la leçon 1 et des tableaux associatifs d'awk de la leçon 6, avec une différence de coût à connaître : il trie tout le tableau. Les groupes sortent dans l'ordre des clés, ce qui rend le résultat stable d'une exécution à l'autre, contrairement à for (k in t) d'awk.
reduce : un état qui traverse le flux
reduce SOURCE as $x (INITIAL; MISE_A_JOUR) parcourt les sorties de SOURCE. L'état part de INITIAL ; pour chaque valeur, liée à $x, MISE_A_JOUR reçoit l'état courant en entrée (le .) et produit le nouvel état. La sortie est l'état final. C'est la boucle d'accumulation de jq, l'équivalent du { n[$12]++ } END { ... } d'awk :
reduce (inputs | .requete.statut? // empty) as $s ({}; .[$s | tostring] += 1)foreach, sa variante, émet l'état à chaque étape ; on s'en sert pour des cumuls (une somme courante, par exemple).
Les objets vus comme des listes
Beaucoup de transformations sont plus simples sur une liste de paires que sur un objet. Trois fonctions font l'aller et le retour :
to_entriestransforme{"a": 1}en[{"key": "a", "value": 1}];from_entriesfait l'inverse, et accepte aussiname,KeyetNamecomme nom de clé (jq 1.7.1) ;with_entries(f)vautto_entries | map(f) | from_entries: on modifie ou filtre les paires, puis on reconstruit l'objet.
from_entries sert aussi à fabriquer un objet à partir de données qui n'en étaient pas : les étiquettes "env=prod" de la CLI scw, par exemple.
Les chemins
jq désigne un emplacement dans un document par un chemin, un tableau de clés et d'indices : ["resultats", 0, "position", "lat"]. Plusieurs fonctions en tirent parti :
pathsetpaths(f)produisent les chemins de toutes les valeurs (ou de celles qui vérifientf, commepaths(scalars)) ;getpath(p)lit une valeur,setpath(p; v)l'écrit,delpaths([p...])et surtoutdel(f)suppriment ;..parcourt récursivement toutes les valeurs du document, à toute profondeur.
Mettre à jour : =, |= et leurs cousins
Les affectations de jq ne modifient rien en place : elles produisent une copie modifiée de l'entrée. La différence entre les deux formes de base tient à ce que voit le côté droit :
.a = févaluefsur l'entrée entière, puis place le résultat en.a..b.a = .acopie le champade la racine dansb..a |= févaluefsur la valeur actuelle de.a..b.a |= . + 10ajoute 10 àb.a.+=,-=,*=,/=combinent :.a += 1vaut.a |= . + 1(à un détail près : le côté droit est évalué sur l'entrée entière, comme pour=).//=donne une valeur par défaut :.seuils.p95_ms //= 500ne touche à rien si la clé existe et vaut autre chose quenulloufalse.
Le côté gauche est un chemin, pas une valeur : .resultats[].etat |= ... met à jour l'état de chaque résultat, et le reste du document est conservé tel quel.
Toutes les façons de lire une entrée
Par défaut, jq lit les documents JSON de ses fichiers ou de son entrée standard, un par un, et applique le filtre à chacun. Les options changent ce modèle :
| Option | Effet | Usage |
|---|---|---|
-s (slurp) | lit tous les documents dans un seul tableau | petits volumes, calculs sur l'ensemble |
-n | n'appelle le filtre qu'une fois, avec null ; les documents se lisent par input (le suivant) et inputs (tous les suivants, en flux) | gros volumes, agrégats |
-R | lit des lignes de texte brut, une chaîne par ligne (avec -s : tout le texte en une chaîne) | relire la sortie d'awk ou un TSV |
--arg nom v | $nom vaut la chaîne v | valeurs du shell |
--argjson nom v | $nom vaut la valeur JSON v (nombre, objet...) | nombres, booléens |
--slurpfile nom f | $nom vaut le tableau des documents du fichier f | combiner des fichiers JSON |
--rawfile nom f | $nom vaut le contenu du fichier f, en une chaîne | combiner des fichiers texte |
--args / --jsonargs | les arguments qui suivent le filtre vont dans $ARGS.positional | listes de valeurs |
$ENV, env | les variables d'environnement | configuration |
-n avec inputs est la forme à retenir pour les gros flux : [inputs | ...] ne garde que ce que le filtre extrait, alors que -s charge tout avant le premier calcul. La partie Sous le capot mesure l'écart.
Fonctions et fichiers de filtres
def nom: corps; définit une fonction, def nom(f): ...; une fonction qui reçoit un filtre en paramètre, def nom($x): ...; une fonction qui reçoit une valeur. Un filtre qui dépasse une ligne se range dans un fichier, appelé par jq -f fichier.jq : on y met des commentaires (#), des définitions, et il se versionne et se teste comme du code. import "nom" as m; et l'option -L organisent des bibliothèques ; nos filtres restent assez courts pour s'en passer.
Expressions régulières
jq 1.7 utilise la bibliothèque Oniguruma, dont la syntaxe est proche de celle de Perl (leçon 2) : \d, \s, groupes nommés (?<nom>...), quantificateurs non gourmands. Les fonctions : test(re) (vrai ou faux), match(re) (objet détaillé), capture(re) (objet des groupes nommés), scan(re) (toutes les correspondances), splits(re) (découpe), sub(re; remplacement) et gsub (toutes les occurrences). Les drapeaux se passent en second argument : "g" (global), "i" (casse), "x" (mode étendu, espaces et commentaires ignorés). Dans un remplacement, "\(.nom)" insère un groupe nommé. Attention aux deux niveaux d'échappement : "\\d" dans le texte du filtre devient \d pour Oniguruma.
Dates
fromdate (ou fromdateiso8601) convertit "2026-10-07T14:02:06Z" en temps Unix, todate fait l'inverse, strftime et strptime acceptent des formats. Ces fonctions attendent le format exact %Y-%m-%dT%H:%M:%SZ : pas de fractions de seconde. Nos journaux JSON en ont (14:02:06.000Z), il faut donc les retirer avant conversion. Pour comparer ou regrouper par jour, heure ou minute, découper la chaîne (.horodatage[11:16]) suffit : le format ISO 8601 en UTC se trie dans l'ordre chronologique.
En pratique
Les sorties ont été produites avec jq 1.7.1 (paquet d'Ubuntu 24.04) et mawk 1.3.4, sous LC_ALL=C.UTF-8, sur le bac à sable de la leçon 1. Sauf mention contraire, les commandes se lancent depuis ~/essais-texte ; les scripts vivent dans le dépôt ~/signalements-outils, dans bin/ et lib/.
Construire un inventaire
La leçon 7 extrayait des champs de scw/serveurs.json. Pour la fiche d'inventaire, on veut un objet par serveur, avec nos propres noms de champs :
$ jq -c '.[] | {nom: .name, zone, etat: .state, ip_privee: .private_nics[0].ip,
ip_publique: (.public_ips[0].address // null)}' scw/serveurs.json
{"nom":"sig-app-1","zone":"fr-par-1","etat":"running","ip_privee":"172.16.8.11","ip_publique":"51.15.97.37"}
{"nom":"sig-app-2","zone":"fr-par-2","etat":"running","ip_privee":"172.16.8.12","ip_publique":"51.15.73.124"}
{"nom":"sig-outils","zone":"fr-par-1","etat":"running","ip_privee":"172.16.8.30","ip_publique":null}
{"nom":"sig-app-3","zone":"fr-par-1","etat":"stopped","ip_privee":"172.16.8.13","ip_publique":null}
zoneseul est le raccourci dezone: .zone..public_ips[0].addresssur un tableau vide donnenullsans erreur : indexernulldonnenull. Le// nulln'ajoute donc rien ici ; il documente l'intention, et l'on y mettrait// "aucune"pour un affichage.- Les parenthèses autour de
(.public_ips[0].address // null)sont nécessaires : sans elles,//s'appliquerait à tout ce qui précède dans l'objet.
Pour un tableau à coller dans la fiche de serveur, ou à passer à awk, on construit un tableau par serveur et on le formate en TSV :
$ jq -r '.[] | [.name, .zone, .state, .private_nics[0].ip, (.public_ips[0].address // "aucune")] | @tsv' scw/serveurs.json
sig-app-1 fr-par-1 running 172.16.8.11 51.15.97.37
sig-app-2 fr-par-2 running 172.16.8.12 51.15.73.124
sig-outils fr-par-1 running 172.16.8.30 aucune
sig-app-3 fr-par-1 stopped 172.16.8.13 aucune
Le produit cartésien annoncé plus haut se voit dès qu'une valeur produit plusieurs sorties :
$ jq -n -c '{hote: ("sig-app-1", "sig-app-2"), port: (8000, 9100)}'
{"hote":"sig-app-1","port":8000}
{"hote":"sig-app-1","port":9100}
{"hote":"sig-app-2","port":8000}
{"hote":"sig-app-2","port":9100}
Utile pour générer une liste de cibles de supervision, piégeux quand on ne s'y attend pas : un .tags[] glissé dans un objet multiplie les objets par le nombre d'étiquettes.
Transformer des étiquettes en objet
La CLI scw rend les étiquettes sous forme de chaînes clé=valeur. Un objet est plus pratique pour les interroger :
$ jq -c '.[0].tags | map(split("=") | {key: .[0], value: .[1]}) | from_entries' scw/serveurs.json
{"app":"signalements","env":"prod","role":"api"}
Et pour tout l'inventaire, un objet indexé par nom de serveur, grâce à une clé calculée et à add, qui fusionne des objets :
$ jq -c 'map({(.name): (.tags | map(split("=") | {key: .[0], value: .[1]}) | from_entries)}) | add' scw/serveurs.json
{"sig-app-1":{"app":"signalements","env":"prod","role":"api"},"sig-app-2":{"app":"signalements","env":"prod","role":"api"},"sig-outils":{"app":"signalements","env":"prod"},"sig-app-3":{"app":"signalements","env":"prod","role":"api"}}
Une étiquette qui contiendrait un = dans sa valeur serait tronquée par .[1] ; split("=") | {key: .[0], value: (.[1:] | join("="))} la conserve entière.
Regrouper et compter
Les serveurs par zone, et leur nombre :
$ jq -c 'group_by(.zone) | map({zone: .[0].zone, serveurs: map(.name)})' scw/serveurs.json
[{"zone":"fr-par-1","serveurs":["sig-app-1","sig-outils","sig-app-3"]},{"zone":"fr-par-2","serveurs":["sig-app-2"]}]
$ jq -c 'map(.zone) | group_by(.) | map({key: .[0], value: length}) | from_entries' scw/serveurs.json
{"fr-par-1":3,"fr-par-2":1}
Lisez la première à voix haute : grouper par zone donne un tableau de groupes ; pour chaque groupe (un tableau de serveurs), construire un objet avec la zone du premier élément, que tous partagent, et la liste des noms. Le .[0] sur un groupe est l'idiome de group_by : tous les éléments d'un groupe ont la même valeur de clé.
Les tests d'existence s'écrivent avec any et all, plus lisibles qu'un select suivi de length > 0 :
$ jq -c 'map(select(any(.tags[]; . == "role=api")) | .name)' scw/serveurs.json
["sig-app-1","sig-app-2","sig-app-3"]
$ jq -c '[any(.[]; .state == "stopped"), all(.[]; .zone | startswith("fr-par"))]' scw/serveurs.json
[true,true]
Le second résultat est une petite vérification de conformité : une instance est arrêtée (on paie encore son volume, rappelle le cours sur le cloud), et toutes les instances sont à Paris, ce que le contrat avec la mairie exige.
Retoucher la réponse de l'API
Le service informatique de la mairie importe les signalements depuis l'API, mais son outil veut des états lisibles, et n'a que faire des coordonnées et des pièces jointes. On part de api/signalements-page1.json, la réponse paginée de l'API. D'abord, voir ce qu'il y a :
$ jq -c '.resultats[] | {id, commune, etat, pj: (.pieces_jointes | length)}' api/signalements-page1.json
{"id":12000,"commune":"Exempleville","etat":"resolu","pj":0}
{"id":12001,"commune":"Exempleville","etat":"en_cours","pj":1}
{"id":12002,"commune":"Exempleville","etat":"resolu","pj":1}
$ jq -c '[.resultats[0] | paths(scalars)]' api/signalements-page1.json
[["id"],["type"],["commune"],["cree_le"],["position","lat"],["position","lon"],["etat"]]
paths(scalars) liste les chemins de toutes les valeurs simples. pieces_jointes n'apparaît pas pour ce premier résultat : son tableau est vide, il ne contient aucun scalaire. C'est la façon la plus rapide de découvrir la forme d'un document inconnu, plus fiable qu'un coup d'œil sur les premières lignes.
Lire un chemin, en supprimer :
$ jq -c '.resultats[0] | getpath(["position","lat"]), del(.position, .pieces_jointes)' api/signalements-page1.json
48.7014
{"id":12000,"type":"signalisation","commune":"Exempleville","cree_le":"2026-10-05T00:08:00Z","etat":"resolu"}
Puis la transformation complète, qui conserve l'enveloppe de pagination et ne touche qu'aux résultats :
jq '.resultats |= map(
del(.position, .pieces_jointes)
| .etat |= ({nouveau: "Nouveau", en_cours: "En cours", resolu: "Résolu"}[.] // .)
)' api/signalements-page1.json.resultats |= map(...)remplace le tableau par sa version transformée ;total,pageetsuivantrestent intacts.{...}[.]est une table de correspondance : un objet littéral indexé par la valeur courante.// .garde la valeur d'origine si l'état est inconnu, plutôt que de produirenull: un nouvel état ajouté côté API ne disparaîtra pas en silence.
$ jq -c '.resultats |= map(.etat |= ({nouveau: "Nouveau", en_cours: "En cours", resolu: "Résolu"}[.] // .)) | .resultats[].etat' api/signalements-page1.json
"Résolu"
"En cours"
"Résolu"
Et pour la même opération sur les clés plutôt que sur les valeurs, with_entries :
$ jq -n -c '{"taux_5xx_pct": 5, "p95_ms": 300} | with_entries(.key |= ascii_upcase)'
{"TAUX_5XX_PCT":5,"P95_MS":300}
= contre |=, sur un exemple
$ jq -n -c '{a: 1, b: {a: 2}} | (.b.a = .a), (.b.a |= . + 10)'
{"a":1,"b":{"a":1}}
{"a":1,"b":{"a":12}}
$ jq -n -c '{a: 1, b: {a: 2}} | .b |= .a'
{"a":1,"b":2}
Dans .b.a = .a, le .a de droite est lu sur la racine (1). Dans .b.a |= . + 10, le . de droite est l'ancienne valeur de b.a (2). Dans .b |= .a, le .a de droite est lu dans b : on remplace b par son propre champ a. Quand le résultat d'une affectation surprend, demandez-vous d'abord sur quelle entrée le côté droit est évalué.
L'incident, minute par minute
Pour le compte rendu d'incident, on veut le nombre de réponses 503 par minute, et les bornes de l'épisode. Les journaux JSON de sig-app-2 suffisent :
$ jq -n -r '[inputs | select(.requete.statut? == 503) | .horodatage[11:16]]
| group_by(.) | .[] | [.[0], length] | @tsv' journaux/sig-app-2*/api.jsonl | head -5
14:02 5
14:03 10
14:04 3
14:05 3
14:06 6
$ jq -n -c '[inputs | select(.requete.statut? == 503) | .horodatage[11:16]]
| unique | {premiere: first, derniere: last, minutes: length}' journaux/sig-app-2*/api.jsonl
{"premiere":"14:02","derniere":"14:19","minutes":18}
-netinputs: le filtre ne s'exécute qu'une fois, et parcourt tous les documents..requete.statut?: l'objet d'avertissement « pool de connexions saturé » n'a pas derequete. Sur un objet,.requete.statutdonnerait simplementnull; le?protège en plus contre une valeur qui ne serait pas un objet (une ligne JSON qui serait un nombre, par exemple)..horodatage[11:16]découpe la chaîne2026-10-07T14:03:12.481Z: caractères 11 à 15, soit14:03. Une tranche de chaîne en jq compte en points de code, comme${var:11:5}en Bash.- Le filtre ne garde que des chaînes de cinq caractères, pas les objets entiers : c'est la projection précoce qui fait toute la différence de mémoire mesurée plus loin.
La même série, si l'on voulait convertir les horodatages en temps Unix, bute sur les fractions de seconde :
$ jq -n '"2026-10-07T14:02:06.000Z" | fromdate'
jq: error (at <unknown>): date "2026-10-07T14:02:06.000Z" does not match format "%Y-%m-%dT%H:%M:%SZ"
$ jq -n '"2026-10-07T14:02:06.000Z" | .[:19] + "Z" | fromdate | todate'
"2026-10-07T14:02:06Z"
On retire les fractions en gardant les 19 premiers caractères, et l'on remet le Z.
Les communes, et la limite de jq 1.7
Les chemins de l'API contiennent la commune demandée, encodée pour l'URL. capture l'extrait par un groupe nommé :
$ jq -r '.requete.chemin? // empty | capture("[?&]commune=(?<c>[^&]+)").c' journaux/*/api.jsonl | sort | uniq -c | sort -rn
579 Exempleville
190 Saint-Exemple%2C%20centre
149 Val-d%27Essai
89 Bourg-T%C3%A9moin
66 Les%20Essarts-du-Test
// emptyécarte l'objet d'avertissement, qui n'a pas de chemin ;emptyne produit aucune sortie.capturene produit rien quand l'expression ne correspond pas (pour/signalements/12345) : pas besoin deselect(test(...))préalable.
Reste le décodage des %2C, %27 et %C3%A9. jq 1.7 sait encoder (@uri), pas décoder : le format @urid n'est arrivé qu'avec jq 1.8.0, selon le fichier NEWS du projet, et Ubuntu 24.04 comme Debian 13 fournissent jq 1.7.1. Écrire un décodeur en jq est possible, mais pénible dès qu'un caractère occupe plusieurs octets (é vaut %C3%A9). C'est un cas d'école pour la fin de la leçon : un outil qui sait le faire en une ligne existe déjà sur la machine.
$ jq -r '.requete.chemin? // empty | capture("[?&]commune=(?<c>[^&]+)").c' journaux/*/api.jsonl \
| python3 -c 'import sys, urllib.parse
for ligne in sys.stdin: print(urllib.parse.unquote(ligne.rstrip("\n")))' | sort | uniq -c | sort -rn
579 Exempleville
190 Saint-Exemple, centre
149 Val-d'Essai
89 Bourg-Témoin
66 Les Essarts-du-Test
Les chiffres concordent avec le rapport de la mairie de la leçon 6 dans leur ordre de grandeur : Exempleville domine, et l'on voit que les habitants de Bourg-Témoin consultent beaucoup l'application pour une petite commune.
Compter avec reduce
La répartition des codes HTTP, en une passe et sans tri :
$ jq -n -c 'reduce (inputs | .requete.statut? // empty) as $s ({}; .[$s | tostring] += 1)' journaux/*/api.jsonl
{"200":3495,"201":351,"304":193,"500":13,"404":111,"503":101}
- L'état initial est l'objet vide ; à chaque statut, la clé correspondante est incrémentée.
+= 1sur une clé absente part denull, etnull + 1vaut 1 en jq. - Les clés d'un objet JSON sont des chaînes :
.[503]sur un objet est une erreur, d'oùtostring. - L'ordre des clés est celui de leur première apparition. Pour un affichage trié, ajoutez
-S(--sort-keys), ou passez parto_entries | sort_by(.key).
Les 351 réponses 201 correspondent aux 351 signalements créés pendant la période, le total que la réponse de l'API annonce dans son champ total : une vérification croisée gratuite entre deux sources indépendantes.
Les latences par hôte, trois versions
Le rapport technique veut, par hôte, le nombre de requêtes, la médiane (p50), le 95e centile (p95) et le maximum des durées, et le nombre d'erreurs 5xx. Un centile p95 est la valeur sous laquelle se trouvent 95 % des mesures ; la méthode la plus simple, dite du rang le plus proche, trie les valeurs et prend celle de rang ⌈0,95 × n⌉. Elle a l'avantage de toujours renvoyer une valeur réellement observée.
La version la plus directe charge tout avec -s, puis regroupe :
map(select(has("requete")))
| group_by(.hote)
| map(...)La version que l'on a envie d'écrire quand on vient d'awk accumule les durées dans un reduce, comme on remplirait un tableau associatif :
reduce (inputs | select(has("requete"))) as $o ({};
.[$o.hote].durees += [$o.requete.duree_ms]
| .[$o.hote].erreurs += (if $o.requete.statut >= 500 then 1 else 0 end))Et la version retenue, rangée dans lib/latences.jq, lit le flux avec inputs mais projette chaque document sur trois champs avant de le garder :
# latences.jq : latences et erreurs par hôte, d'après les journaux JSON de l'API.
# Usage : jq -n -f lib/latences.jq journaux/*/api.jsonl
# Centile par la méthode du rang le plus proche ; l'entrée est un tableau trié.
def centile($p): .[(length * $p | ceil) - 1];
# 1. Ne garder de chaque requête que ce qui sert, au fil de la lecture.
[inputs
| select(has("requete"))
| {hote, duree: .requete.duree_ms, erreur: (.requete.statut >= 500)}]
# 2. Regrouper par hôte et résumer chaque groupe.
| group_by(.hote)
| map((map(.duree) | sort) as $d
| {hote: .[0].hote,
requetes: length,
p50_ms: ($d | centile(0.50)),
p95_ms: ($d | centile(0.95)),
max_ms: $d[-1],
erreurs: (map(select(.erreur)) | length)})$ jq -n -f ~/signalements-outils/lib/latences.jq journaux/*/api.jsonl
[
{
"hote": "sig-app-1",
"requetes": 2072,
"p50_ms": 29,
"p95_ms": 80,
"max_ms": 222,
"erreurs": 7
},
{
"hote": "sig-app-2",
"requetes": 2192,
"p50_ms": 31,
"p95_ms": 144,
"max_ms": 30019,
"erreurs": 107
}
]
Les points qui méritent l'attention :
def centile($p):$pest un paramètre valeur.length * $p | ceilcalcule le rang (2192 × 0,95 = 2082,4, arrondi à 2083), et- 1le convertit en indice, puisque les tableaux commencent à 0.(map(.duree) | sort) as $dlie le tableau trié à une variable, pour s'en servir trois fois sans trier trois fois.asne change pas l'entrée : aprèsas $d | ..., le.est toujours le groupe.$d[-1]: un indice négatif compte depuis la fin ; c'est le maximum, puisque le tableau est trié.- Le résultat raconte l'incident : la médiane de
sig-app-2est normale (31 ms), son p95 double (144 ms contre 80), et son maximum, 30 019 ms, est le délai d'attente de la connexion à la base. Un p95 sur quatre jours dilue un incident de dix-huit minutes ; c'est le maximum et le compte d'erreurs qui le révèlent. Le cours SLI, SLO et budgets d'erreur (à venir) reviendra sur le choix de ces indicateurs.
Tester un filtre
Un fichier .jq se teste comme une fonction Bash à la leçon 12 du cours Bash : une entrée maîtrisée, une sortie attendue, une comparaison. On écrit à la main un petit jeu de données dans le dépôt, avec les cas qui comptent (un objet sans requete, une erreur 503, un hôte à une seule requête) :
$ cd ~/signalements-outils
$ cat tests/latences.jsonl
{"hote":"a","requete":{"statut":200,"duree_ms":10}}
{"hote":"a","requete":{"statut":200,"duree_ms":30}}
{"hote":"a","requete":{"statut":503,"duree_ms":30000}}
{"hote":"a","requete":{"statut":200,"duree_ms":20}}
{"hote":"b","message":"pool de connexions saturé"}
{"hote":"b","requete":{"statut":404,"duree_ms":5}}
$ jq -c -n -f lib/latences.jq tests/latences.jsonl
[{"hote":"a","requetes":4,"p50_ms":20,"p95_ms":30000,"max_ms":30000,"erreurs":1},{"hote":"b","requetes":1,"p50_ms":5,"p95_ms":5,"max_ms":5,"erreurs":0}]
On vérifie à la main chaque valeur avant de l'adopter comme référence : pour a, durées triées 10, 20, 30, 30000 ; rang de la médiane ⌈4 × 0,5⌉ = 2, soit 20 ; rang du p95 ⌈3,8⌉ = 4, soit 30000. Puis on enregistre la sortie comme attendue, et le test devient une ligne dans la CI :
$ jq -c -n -f lib/latences.jq tests/latences.jsonl > tests/latences.attendu.json
$ diff <(jq -c -n -f lib/latences.jq tests/latences.jsonl) tests/latences.attendu.json && echo "latences.jq : OK"
latences.jq : OK
-c donne une sortie compacte et stable ; pour comparer des objets dont l'ordre des clés pourrait varier, ajoutez -S, qui trie les clés.
Un message d'alerte, sans assembler de JSON
Le script de surveillance doit prévenir l'équipe sur sa messagerie Mattermost, qui accepte des messages par webhook entrant : une URL secrète à laquelle on envoie, par une requête POST, un document JSON dont le champ text est le message. La documentation de Mattermost décrit ce format, que Slack et d'autres outils partagent. Voici d'abord ce qui arrive avec la méthode de Camille, quand une valeur contient un guillemet :
$ hote='sig-app-2 "test"'
$ printf '{"text": "Alerte sur %s"}\n' "$hote" | jq -c .
jq: parse error: Invalid literal at line 1, column 37
Le JSON produit est invalide. Avec jq, la valeur entre par --arg et ressort correctement échappée, quoi qu'elle contienne :
$ jq -n -c --arg hote "$hote" '{text: "Alerte sur \($hote)"}'
{"text":"Alerte sur sig-app-2 \"test\""}
L'interpolation "\($hote)" insère la valeur dans une chaîne JSON, et c'est jq qui sérialise le résultat, avec les échappements de la RFC 8259. Le message réel de rapport-hebdo :
$ jq -n --arg hote sig-app-2 --arg taux 15.54 --arg seuil 5 \
'{text: "Taux de 5xx de \($taux) % sur \($hote) (seuil : \($seuil) %)", username: "rapport-hebdo"}'
{
"text": "Taux de 5xx de 15.54 % sur sig-app-2 (seuil : 5 %)",
"username": "rapport-hebdo"
}
text est le seul champ obligatoire ; c'est du Markdown. username remplace le nom affiché, à condition que l'option « Enable integrations to override usernames » soit activée sur le serveur Mattermost, précise sa documentation ; sinon, le message s'affiche sous le nom choisi à la création du webhook.
L'envoi passe le document à curl par l'entrée standard, sans jamais le mettre sur la ligne de commande :
jq -n --arg hote "$hote" --arg taux "$taux" --arg seuil "$seuil" \
'{text: "Taux de 5xx de \($taux) % sur \($hote) (seuil : \($seuil) %)", username: "rapport-hebdo"}' |
curl --fail --silent --show-error --max-time 10 \
-H 'Content-Type: application/json' --data-binary @- "$URL_WEBHOOK"--data-binary @-lit le corps sur l'entrée standard, sans le transformer (-dsupprimerait les sauts de ligne).--failfait échouercurlsur une réponse HTTP d'erreur : c'est ce qui manquait au script de Camille pour que l'erreur 400 se voie. Avecset -o pipefail(leçon 9 du cours Bash), l'échec remonte au script.- L'URL du webhook est un secret (quiconque la connaît peut écrire dans le canal) : elle vient de l'environnement ou d'un fichier lisible par le seul compte du service, jamais du dépôt.
Pour décider s'il faut alerter, -e transforme le résultat d'un test en code de sortie (leçon 7) :
$ rapport-hebdo --donnees ~/essais-texte --format json > rapport.json
$ jq -e --argjson seuil 5 'any(.trafic[]; .taux_5xx_pct > $seuil)' rapport.json; echo "code $?"
true
code 0
$ jq -e --argjson seuil 20 'any(.trafic[]; .taux_5xx_pct > $seuil)' rapport.json; echo "code $?"
false
code 1
$ jq -r --argjson seuil 5 '.trafic[] | select(.taux_5xx_pct > $seuil) | "\(.hote) \(.jour) \(.taux_5xx_pct) %"' rapport.json
sig-app-2 2026-10-07 15.54 %
--argjson et non --arg : avec --arg seuil 5, $seuil serait la chaîne "5", et la comparaison d'un nombre avec une chaîne suit l'ordre total de jq, où toute chaîne est plus grande que tout nombre. Le test serait toujours faux, sans la moindre erreur.
Modifier un fichier JSON sans le vider
Le fichier de seuils de la surveillance, alertes.json :
{
"seuils": {"taux_5xx_pct": 5, "p95_ms": 500},
"webhook": "https://chat.lyneko.example/hooks/xxxxxxxx",
"destinataires": ["equipe-signalements"]
}Les mises à jour s'écrivent avec les affectations :
$ jq -c '.seuils.p95_ms = 300 | .destinataires += ["astreinte"]' alertes.json
{"seuils":{"taux_5xx_pct":5,"p95_ms":300},"webhook":"https://chat.lyneko.example/hooks/xxxxxxxx","destinataires":["equipe-signalements","astreinte"]}
$ jq -c '.seuils.taux_5xx_pct //= 1 | .seuils.latence_max_ms //= 30000' alertes.json
{"seuils":{"taux_5xx_pct":5,"p95_ms":500,"latence_max_ms":30000},"webhook":"https://chat.lyneko.example/hooks/xxxxxxxx","destinataires":["equipe-signalements"]}
//= n'a pas touché à taux_5xx_pct, qui existait, et a ajouté latence_max_ms, qui manquait : c'est la forme idempotente de « ajouter une valeur par défaut », l'équivalent JSON de ce que la leçon 4 faisait péniblement avec sed sur un fichier INI.
Reste à réécrire le fichier. La redirection naïve détruit tout :
$ jq '.seuils.p95_ms = 300' alertes.json > alertes.json
$ wc -c alertes.json
0 alertes.json
Le shell ouvre alertes.json en écriture, et le tronque, avant même de lancer jq ; jq lit un fichier vide, ne produit rien, et le fichier reste vide. C'est le piège du sort fichier > fichier de la leçon 1, et jq n'a pas d'option -i comme sed. La méthode sûre écrit dans un fichier temporaire du même répertoire, puis le renomme :
f=alertes.json
tmp=$(mktemp "$f.XXXXXX")
jq '.seuils.p95_ms = 300' "$f" > "$tmp" &&
chmod --reference="$f" "$tmp" &&
mv -- "$tmp" "$f"$ stat -c '%a %s %n' alertes.json
640 174 alertes.json
$ jq -c .seuils alertes.json
{"taux_5xx_pct":5,"p95_ms":300}
&&: si jq échoue (filtre faux, fichier d'origine déjà invalide), le renommage n'a pas lieu et l'original reste intact. Dans un script, ajoutez un piège qui supprime le temporaire (leçon 10 du cours Bash).- Le même répertoire :
mvest alors unrename, atomique ; un lecteur voit l'ancien fichier ou le nouveau, jamais un fichier à moitié écrit. Dans/tmp, sur un autre système de fichiers,mvdeviendrait une copie. chmod --reference:mktempcrée le fichier avec les droits 600. Sans cette ligne, un fichier qui était lisible par le groupe du service (640) ne le serait plus après la modification, et le service tomberait en panne au prochain redémarrage. Si le propriétaire compte aussi,chown --referenceenroot.
La commande sponge, du paquet moreutils (version 0.69 sur Debian 13 et Ubuntu 24.04), « éponge » toute son entrée avant d'ouvrir le fichier de sortie : jq '...' f | sponge f. Elle évite la troncature, mais pas tout. sponge ne connaît pas le code de sortie de jq : si jq échoue, il écrit quand même ce qu'il a reçu, c'est-à-dire rien, et pipefail ne signale l'échec qu'après coup, le fichier déjà vidé. Et son écriture n'est atomique que si son fichier temporaire, créé dans TMPDIR, est sur le même système de fichiers que la cible : d'après le code source de moreutils 0.69, quand le renommage est impossible (un /tmp en tmpfs, le défaut de Debian 13), il recopie le contenu dans le fichier existant. Le fichier temporaire créé dans le répertoire de la cible, rempli seulement si jq a réussi, puis renommé par mv, reste la méthode sûre.
Note
jq conserve l'ordre des clés du document d'origine, mais pas sa mise en forme : l'indentation, les espaces et les retours à la ligne sont ceux de jq (deux espaces par défaut, --indent n, --tab). Le premier passage d'un fichier écrit à la main produit donc un gros diff. Formatez le fichier une fois avec jq, validez ce changement seul, et les modifications suivantes ne montreront que l'essentiel.
Assembler rapport-hebdo
Tout est prêt. Les leçons précédentes ont produit trois programmes awk, cette leçon un filtre jq ; il manque le chef d'orchestre. Rappel des contrats :
| Programme | Entrée | Sortie |
|---|---|---|
lib/requetes.awk (leçon 5) | un syslog.log | TSV date heure hote methode chemin code, une ligne par requête de l'API |
lib/rapport.awk (leçon 6) | le TSV précédent | tableau Markdown du trafic par hôte et par jour, hors /sante |
lib/communes.awk (leçon 6) | référentiel puis exports CSV | tableau Markdown des signalements par commune |
lib/latences.jq (cette leçon) | les api.jsonl | tableau JSON des latences par hôte |
Deux pièces s'ajoutent. lib/ssh.awk compte les connexions SSH refusées par adresse source, à partir des lignes sshd vues à la leçon 2 :
# ssh.awk : tentatives de connexion SSH refusées, par adresse source (TSV ip, nombre).
$3 ~ /^sshd\[[0-9]+\]:$/ && $4 == "Invalid" && $5 == "user" { n[$(NF - 2)]++ }
$3 ~ /^sshd\[[0-9]+\]:$/ && / by authenticating user / { n[$(NF - 3)]++ }
END { for (ip in n) printf "%s\t%d\n", ip, n[ip] }Une tentative sur un compte inexistant produit deux lignes (Invalid user ... from IP port P, puis Connection closed by invalid user ...) : on ne compte que la première. Une tentative sur root, qui existe, n'en produit qu'une (Connection closed by authenticating user root IP port P [preauth]), où l'adresse est le quatrième champ avant la fin. On aurait pu écrire grep -oE ... | sort | uniq -c ; dans un script sous set -e et pipefail, un grep qui ne trouve rien renvoie 1 et arrête tout, alors qu'une semaine sans tentative SSH est une bonne nouvelle, pas une erreur. awk renvoie 0 qu'il trouve ou non.
Et lib/rapport-json.jq assemble la version JSON à partir des fichiers intermédiaires :
# rapport-json.jq : assemble le rapport hebdomadaire au format JSON.
# Variables attendues : $debut, $fin, $communes, $trafic, $ssh (textes), $latences, $sondes.
# Lignes de données d'un tableau Markdown produit par nos scripts awk :
# un tableau de cellules par ligne, chaque cellule numérique convertie en nombre.
def lignes_md:
split("\n")
| map(select(startswith("|")))
| .[2:]
| map(ltrimstr("| ") | rtrimstr(" |") | split(" | ") | map(tonumber? // .));
# Lignes d'un TSV, découpées en cellules.
def lignes_tsv: split("\n") | map(select(length > 0) | split("\t"));
{
periode: {debut: $debut, fin: $fin},
communes: ($communes | lignes_md | map({
commune: .[0], population: .[1], signalements: .[2],
pour_1000_habitants: .[3], type_principal: .[4]})),
trafic: ($trafic | lignes_md | map({
hote: .[0], jour: .[1], requetes: .[2], erreurs_4xx: .[3], erreurs_5xx: .[4],
taux_5xx_pct: (.[5] | rtrimstr(" %") | tonumber)})),
latences: $latences[0],
securite: {
ssh: ($ssh | lignes_tsv | map({ip: .[0], tentatives: (.[1] | tonumber)})),
sondes: $sondes
}
}lignes_mdrelit les tableaux Markdown des scripts awk : garder les lignes qui commencent par|, sauter l'en-tête et la ligne de séparation (.[2:]), retirer les bordures, découper sur|.tonumber? // .convertit les cellules numériques et laisse les autres en chaînes :tonumberéchoue surExempleville, le?transforme l'erreur en absence de sortie, et//prend alors la valeur d'origine.- Relire du Markdown est un compromis assumé : les scripts awk de la leçon 6 produisent directement le tableau pour la mairie, et l'on évite de dupliquer leurs calculs. C'est un contrat entre nos propres programmes, couvert par des tests ; on ne relirait pas ainsi un tableau écrit par un tiers. La partie En production discute l'autre conception.
$latences[0]:--slurpfilelie toujours un tableau des documents du fichier, même s'il n'en contient qu'un.
Enfin, bin/rapport-hebdo, en Bash, avec les habitudes du cours Bash : mode strict, locale fixée, options analysées, répertoire de travail nettoyé à la sortie, codes de sortie documentés.
#!/usr/bin/env bash
# rapport-hebdo : rapport hebdomadaire de Signalements, pour la mairie et pour l'équipe.
# Usage : rapport-hebdo [--format markdown|json] [--donnees RÉPERTOIRE]
set -Eeuo pipefail
export LC_ALL=C.UTF-8
LIB=$(dirname -- "$(readlink -f -- "$0")")/../lib
readonly LIB
format=markdown
donnees=${RAPPORT_DONNEES:-/srv/donnees}
usage() {
printf 'Usage : %s [--format markdown|json] [--donnees RÉPERTOIRE]\n' "${0##*/}"
}
while (( $# > 0 )); do
case $1 in
--format|--donnees)
(( $# >= 2 )) || { usage >&2; exit 2; }
if [[ $1 == --format ]]; then format=$2; else donnees=$2; fi
shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
done
if [[ $format != markdown && $format != json ]]; then
printf '%s : format inconnu : %s\n' "${0##*/}" "$format" >&2
exit 2
fi
shopt -s nullglob
journaux=("$donnees"/journaux/*/syslog.log)
jsonl=("$donnees"/journaux/*/api.jsonl)
exports=("$donnees"/exports/signalements-*.csv)
if (( ${#journaux[@]} == 0 || ${#jsonl[@]} == 0 || ${#exports[@]} == 0 )); then
printf '%s : journaux ou exports absents sous %s\n' "${0##*/}" "$donnees" >&2
exit 1
fi
debut=${exports[0]##*/signalements-}; debut=${debut%.csv}
fin=${exports[-1]##*/signalements-}; fin=${fin%.csv}
travail=$(mktemp -d)
trap 'rm -rf -- "$travail"' EXIT
# 1. Calculs : un fichier intermédiaire par section, chacun produit par un seul outil.
for journal in "${journaux[@]}"; do
awk -f "$LIB/requetes.awk" "$journal"
done > "$travail/requetes.tsv"
awk -f "$LIB/rapport.awk" "$travail/requetes.tsv" > "$travail/trafic.md"
awk -f "$LIB/communes.awk" "$donnees/referentiel/communes.csv" "${exports[@]}" > "$travail/communes.md"
jq -n -f "$LIB/latences.jq" "${jsonl[@]}" > "$travail/latences.json"
awk -f "$LIB/ssh.awk" "${journaux[@]}" | sort -t $'\t' -k2,2nr -k1,1 > "$travail/ssh.tsv"
sondes=$(awk -F '\t' '$6 == 404 && $5 ~ /^\/(wp-login\.php|\.env|\.git\/|admin\/|phpmyadmin\/)/' \
"$travail/requetes.tsv" | wc -l)
# 2. Présentation.
if [[ $format == json ]]; then
jq -n \
--arg debut "$debut" --arg fin "$fin" \
--rawfile communes "$travail/communes.md" \
--rawfile trafic "$travail/trafic.md" \
--rawfile ssh "$travail/ssh.tsv" \
--slurpfile latences "$travail/latences.json" \
--argjson sondes "$sondes" \
-f "$LIB/rapport-json.jq"
exit 0
fi
printf '# Signalements : rapport du %s au %s\n\n' "$debut" "$fin"
printf '## Signalements par commune\n\n'
cat "$travail/communes.md"
printf '\n## Trafic de l'\''API (hors vérifications de santé)\n\n'
cat "$travail/trafic.md"
printf '\n## Latences (journaux JSON)\n\n'
jq -r '"| Hôte | Requêtes | p50 | p95 | Max | Erreurs 5xx |",
"|---|---:|---:|---:|---:|---:|",
(.[] | "| \(.hote) | \(.requetes) | \(.p50_ms) ms | \(.p95_ms) ms | \(.max_ms) ms | \(.erreurs) |")' \
"$travail/latences.json"
printf '\n## Sécurité\n\n'
awk -F '\t' '{ total += $2; sources++ }
END { printf "- Tentatives de connexion SSH refusées : %d, depuis %d adresses\n", total, sources }' \
"$travail/ssh.tsv"
head -n 3 "$travail/ssh.tsv" | awk -F '\t' '{ printf " - %s : %d\n", $1, $2 }'
printf -- '- Sondes de vulnérabilités (404 sur des chemins connus) : %d\n' "$sondes"Les choix qui méritent une explication :
- Séparer calcul et présentation. Chaque section est calculée une fois, par l'outil le plus adapté, dans un fichier du répertoire de travail ; les deux formats ne font que présenter ces fichiers. Ajouter un format CSV demain ne touchera à aucun calcul.
LIBrelatif au script, résolu parreadlink -f: le script trouve ses bibliothèques où qu'on l'installe et d'où qu'on le lance, y compris par un lien symbolique dans/usr/local/bin.RAPPORT_DONNEESpermet au minuteur systemd de fixer le répertoire sans option ;--donneesprime pour un essai.- La période se lit dans les noms des exports, que le motif trie par ordre alphabétique, donc chronologique grâce aux dates ISO.
${exports[-1]}est le dernier élément (Bash 4.3 et plus). --argjson sondes "$sondes":wc -lproduit un nombre, qu'on veut nombre dans le JSON. Siwcproduisait autre chose qu'un JSON valide, jq refuserait de démarrer, avec une erreur : c'est un contrôle gratuit.- Le tableau des latences en Markdown est produit par jq lui-même : plusieurs sorties séparées par des virgules, l'en-tête, la séparation, puis une ligne par hôte par interpolation. Les valeurs sont des nombres et des noms d'hôtes : rien à échapper ici. Une valeur libre (un message d'erreur) contenant
|casserait le tableau, et demanderait ungsub("\\|"; "\\|").
Le rapport de la semaine, pour de vrai :
$ ~/signalements-outils/bin/rapport-hebdo --donnees ~/essais-texte
# Signalements : rapport du 2026-10-05 au 2026-10-08
## Signalements par commune
| 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 |
## Trafic de l'API (hors vérifications de santé)
| 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 % |
## Latences (journaux JSON)
| Hôte | Requêtes | p50 | p95 | Max | Erreurs 5xx |
|---|---:|---:|---:|---:|---:|
| sig-app-1 | 2072 | 29 ms | 80 ms | 222 ms | 7 |
| sig-app-2 | 2192 | 31 ms | 144 ms | 30019 ms | 107 |
## Sécurité
- Tentatives de connexion SSH refusées : 152, depuis 3 adresses
- 203.0.113.77 : 67
- 203.0.113.150 : 50
- 198.51.100.23 : 35
- Sondes de vulnérabilités (404 sur des chemins connus) : 96
Les chiffres se recoupent entre sources indépendantes, ce qui est le meilleur test d'un rapport : les 107 erreurs 5xx des journaux JSON de sig-app-2 sont la somme des 3 + 1 + 101 + 2 du trafic lu dans les journaux texte ; les 2 192 requêtes JSON sont les 2 240 lignes du trafic texte moins les 48 sondes (douze par jour), que l'application ne journalise pas en JSON.
La version JSON, dont on montre quelques morceaux :
$ ~/signalements-outils/bin/rapport-hebdo --donnees ~/essais-texte --format json > rapport.json
$ jq -c '.periode, .communes[1], .trafic[6], .latences[1], .securite' rapport.json
{"debut":"2026-10-05","fin":"2026-10-08"}
{"commune":"Saint-Exemple, centre","population":12034,"signalements":52,"pour_1000_habitants":4.32,"type_principal":"graffiti"}
{"hote":"sig-app-2","jour":"2026-10-07","requetes":650,"erreurs_4xx":28,"erreurs_5xx":101,"taux_5xx_pct":15.54}
{"hote":"sig-app-2","requetes":2192,"p50_ms":31,"p95_ms":144,"max_ms":30019,"erreurs":107}
{"ssh":[{"ip":"203.0.113.77","tentatives":67},{"ip":"203.0.113.150","tentatives":50},{"ip":"198.51.100.23","tentatives":35}],"sondes":96}
Et les erreurs d'utilisation, avec leurs codes :
$ rapport-hebdo --format yaml; echo "code $?"
rapport-hebdo : format inconnu : yaml
code 2
$ rapport-hebdo --donnees /nulle/part; echo "code $?"
rapport-hebdo : journaux ou exports absents sous /nulle/part
code 1
$ rapport-hebdo --format; echo "code $?"
Usage : rapport-hebdo [--format markdown|json] [--donnees RÉPERTOIRE]
code 2
Warning
La première version de lignes_md filtrait les lignes avec startswith("| "), avec une espace. La ligne de séparation, |---|---|, ne commence pas par | : elle était écartée, puis .[2:] sautait l'en-tête et la première ligne de données. Le rapport JSON ne comptait que quatre communes, sans Exempleville, et aucune erreur ne s'affichait :
$ jq -n -c --rawfile communes communes.md 'def lignes_md: split("\n") | map(select(startswith("| "))) | .[2:] | map(ltrimstr("| ") | rtrimstr(" |") | split(" | ") | map(tonumber? // .)); $communes | lignes_md | length, .[0][0]'
4
"Saint-Exemple, centre"
C'est la comparaison avec la version Markdown qui l'a révélé. Un test qui vérifie le nombre de lignes produites (jq -e '.communes | length == 5') l'aurait attrapé en CI.
Choisir son outil
Le cours se termine sur la question qu'il a posée à chaque leçon. Aucun de ces outils n'est universel, et le plus coûteux n'est pas d'en choisir un mauvais, c'est de forcer celui que l'on connaît sur un format qu'il ne comprend pas.
| Données ou tâche | Outil | Pourquoi | Quand en changer |
|---|---|---|---|
| Trouver des lignes, tester une présence | grep (leçon 2) | le plus rapide, code de sortie exploitable | dès qu'il faut transformer ou compter par champ |
| Substituer, supprimer, extraire par motif, sur des lignes | sed (leçons 3 et 4) | flux sans état, édition sur place | état, calcul, ou fichier structuré (JSON, YAML) |
| Champs à séparateur simple : compter, sommer, joindre, rapporter | awk (leçons 5 et 6) | un langage complet, une passe, portable | CSV avec guillemets, programme de plus de quelques dizaines de lignes |
| JSON, JSON Lines | jq | comprend le format : échappements, imbrication, types | logique applicative, plusieurs sources hétérogènes, besoins absents de jq 1.7 |
| YAML (manifestes Kubernetes, configuration) | yq | même modèle que jq | voir ci-dessous : deux outils différents portent ce nom |
| CSV, TSV avec en-têtes, y compris champs entre guillemets | Miller (mlr) | lit le CSV selon la RFC 4180, convertit en JSON, statistiques par groupe | jointures complexes, gros volumes : SQL |
| Gros volumes, jointures, requêtes ad hoc | SQLite, DuckDB | SQL, index, jointures, lit CSV et JSON | données qui doivent rester dans un flux |
| Logique riche, bibliothèques, tests unitaires | Python | csv, json, urllib.parse, datetime : tout est là | jamais pour une ligne que grep ferait |
Quelques précisions pour les outils que le cours n'a fait que nommer.
yq, deux outils sous un même nom. Le paquet yq de Debian 13 (version 3.4.3) et d'Ubuntu 24.04 (3.1.0) est le yq Python d'Andrey Kislyuk : un enveloppeur qui convertit le YAML en JSON, appelle jq avec votre filtre, et reconvertit si on le demande (-y). Tout ce que vous savez de jq s'applique, mais les commentaires du YAML sont perdus. Le yq Go de Mike Farah, très répandu dans les CI et sur macOS, a une syntaxe inspirée de jq mais distincte, sait modifier un fichier en place (-i) en conservant mieux les commentaires, et n'est empaqueté ni par Debian ni par Ubuntu : il s'installe depuis ses publications. Une commande yq trouvée dans une documentation ne marche donc pas forcément avec le yq installé ; vérifiez lequel vous avez (yq --version) avant de la copier. Le cours YAML, JSON et les pièges de configuration (à venir) reviendra sur les pièges propres au YAML.
Miller, version 6.13.0 sur Debian 13, est le jq du CSV : il lit les en-têtes, respecte les guillemets de la RFC 4180, et propose des verbes (sort, count-distinct, stats1, join) qui couvrent ce que la leçon 6 écrivait en awk. Par exemple, d'après sa documentation, mlr --icsv --opprint count-distinct -f commune exports/*.csv compte les signalements par commune sans se tromper sur "Saint-Exemple, centre", et mlr --icsv --ojson cat convertit un CSV en JSON pour jq. Si le format CSV prend de l'importance dans votre travail, c'est l'outil à apprendre ensuite.
SQLite et DuckDB deviennent intéressants quand on pose plusieurs questions aux mêmes données, ou qu'on joint des tables de taille comparable : DuckDB lit directement des fichiers CSV et JSON Lines et les interroge en SQL, sans import préalable. Pour le rapport d'une semaine de Signalements, c'est inutile ; pour un an de journaux de dix serveurs, la question se pose.
Python reste le recours quand la logique domine les données : on l'a vu deux fois dans ce cours, pour le CSV de la leçon 6 et pour le décodage des URL ici. Une règle pratique : quand un filtre jq ou un programme awk demande plus de vingt lignes, ou un deuxième niveau de fonctions, écrivez-le en Python, avec des tests ; quand une ligne de grep, sed, awk ou jq répond à la question, ne lancez pas Python.
Sous le capot
Des valeurs immuables, partagées et copiées
Une affectation de jq produit une copie modifiée : l'entrée n'est jamais modifiée. Pour que ce soit tenable, la bibliothèque jv de jq partage les valeurs par comptage de références, et ne copie un tableau ou un objet qu'au moment de le modifier s'il est encore référencé ailleurs. Quand une valeur n'a qu'un seul propriétaire, jq peut la modifier sur place, sans copie. La différence ne se voit pas dans le résultat, mais se mesure. Sur le bac à sable généré avec --volume 10 (42 641 lignes JSON, 12 Mo), les trois versions du calcul des latences, qui produisent exactement le même résultat :
| Version | Durée | Mémoire maximale |
|---|---|---|
-n, inputs, projection sur trois champs, puis group_by | 0,21 s | 29 Mo |
-s, tout le document en mémoire, puis group_by | 0,39 s | 114 Mo |
-n, reduce avec .[$h].durees += [$d] | 1,88 s | 8 Mo |
Mesures prises avec /usr/bin/time -f '%e s, %M Ko', deux fois de suite, avec jq 1.7.1 ; refaites-les chez vous, l'ordre de grandeur compte, pas les décimales. Trois enseignements :
-scoûte en mémoire environ dix fois la taille du fichier : chaque objet JSON lu devient une structure en mémoire, avec ses clés, ses chaînes et ses en-têtes. À un gigaoctet de journaux, la machine manque de mémoire avant d'avoir commencé à calculer.- Projeter tôt : ne garder que trois champs par requête divise la mémoire par quatre et accélère le tri. C'est le même principe que
cutavantsortà la leçon 1. - L'accumulation par
reduceest économe mais lente dans cette forme. L'ajout à un tableau imbriqué dans l'état passe par la mécanique générale des chemins (getpath, puissetpath, dans la définition de_modifydu fichiersrc/builtin.jq), et le tableau est recopié à chaque tour. On le vérifie en doublant la taille :jq -n 'reduce range(N) as $i ({}; .a.d += [$i])'prend chez nous 0,7 s pour 20 000 éléments, 4 s pour 40 000 et 22 s pour 80 000. Une durée qui fait plus que quadrupler quand la taille double trahit une copie de tout le tableau à chaque ajout. jq n'est pas awk : un tableau associatif qui grossit élément par élément n'y est pas l'idiome naturel. Pour compter (+= 1),reduceest parfait ; pour collecter des listes,[inputs | ...]puisgroup_byvaut mieux.
Les affectations sont des chemins
.a.b |= f n'est pas une écriture en mémoire : jq calcule d'abord les chemins que désigne le côté gauche (path(.a.b) vaut ["a","b"]), puis, pour chacun, lit la valeur (getpath), lui applique f et réécrit le résultat (setpath). C'est pourquoi le côté gauche doit être une expression de chemin : .resultats[].etat en est une (elle désigne autant de chemins qu'il y a de résultats), .a + 1 n'en est pas une, et jq refuse (.a + 1) |= ... avec l'erreur Invalid path expression with result 2 (pour une entrée où .a vaut 1). C'est aussi pourquoi select fonctionne à gauche : (.resultats[] | select(.etat == "nouveau") | .etat) |= "Nouveau" ne met à jour que les chemins retenus.
Les nombres : littéral conservé, calcul en double
Dans le rapport JSON, la population de Val-d'Essai donne "pour_1000_habitants": 5.50, avec son zéro final, alors que 5.50 + 0 donnerait 5.5 :
$ jq -n '"5.50" | tonumber, (tonumber + 0)'
5.50
5.5
Depuis jq 1.7, un nombre lu ou converti conserve son littéral d'origine tant qu'aucun calcul ne le touche ; le moindre calcul le convertit en nombre à virgule flottante double précision, et le littéral est perdu. Pour un outil qui importe le JSON, 5.50 et 5.5 sont le même nombre. Pour un diff ou un test qui compare des textes, ils diffèrent : d'où l'intérêt de comparer avec jq (jq -e '. == $attendu') plutôt qu'avec diff quand des nombres sont en jeu.
--stream : quand même un document ne tient pas en mémoire
inputs aide quand le fichier contient beaucoup de petits documents (JSON Lines). Quand c'est un seul énorme document, par exemple un tableau de plusieurs gigaoctets renvoyé par une API, il faut le lire sans le construire. --stream le transforme en une suite d'événements [chemin, feuille] :
$ jq -c --stream . scw/serveurs.json | head -3
[[0,"id"],"88f5bf1a-d405-4546-8f7b-410b0f5d0d71"]
[[0,"name"],"sig-app-1"]
[[0,"commercial_type"],"PRO2-XXS"]
$ jq -c -n --stream 'fromstream(1 | truncate_stream(inputs)) | .name' scw/serveurs.json
"sig-app-1"
"sig-app-2"
"sig-outils"
"sig-app-3"
truncate_stream(1) retire le premier niveau des chemins (l'indice dans le tableau), et fromstream reconstruit chaque élément dès qu'il est complet. La mémoire reste proportionnelle à un élément. La forme est obscure ; on la garde pour le jour où l'on en a besoin, en sachant qu'elle existe.
Pièges courants
jq '...' f > f. Le shell tronque le fichier avant que jq ne le lise : le fichier est vide. Temporaire du même répertoire, droits recopiés, puis mv.
Assembler du JSON avec printf ou echo. Un guillemet, une barre oblique inverse ou un saut de ligne dans une valeur, et le document est invalide, ou pire, valide avec une autre signification. jq -n --arg, toujours.
--arg pour un nombre. $seuil est alors une chaîne, et 3 > "5" est faux (toute chaîne est plus grande que tout nombre dans l'ordre de jq), sans erreur. --argjson, ou ($seuil | tonumber).
La virgule et le tube. , lie plus fort que |. [...] | add, (.resultats | ...) applique les deux branches à la sortie de [...], pas au document :
$ jq -c '[.resultats[] | .pieces_jointes | length] | add, (.resultats | map(.pieces_jointes | length) | add)' api/signalements-page1.json
2
jq: error (at api/signalements-page1.json:48): Cannot index array with string "resultats"
$ jq -c '([.resultats[] | .pieces_jointes | length] | add), (.resultats | map(.pieces_jointes | length) | add)' api/signalements-page1.json
2
2
Dans le doute, des parenthèses.
Une valeur multiple dans un constructeur. {nom: .name, etiquette: .tags[]} produit un objet par étiquette. Mettez la valeur entre crochets (etiquettes: [.tags[]], ou simplement .tags).
-s sur de gros volumes. Toute la donnée en mémoire, dix fois sa taille. -n et inputs, avec une projection précoce.
.[] sur null. jq '.[0].volumes[]' sur un objet sans volumes échoue avec Cannot iterate over null (null), code 5. .volumes[]? ne produit rien, (.volumes // [])[] itère sur une liste vide : choisissez selon que l'absence est normale ou non.
fromdate sur un horodatage à fractions de seconde. does not match format "%Y-%m-%dT%H:%M:%SZ". Tronquez à 19 caractères et remettez le Z.
group_by qui renvoie des groupes, pas des agrégats. Le résultat est un tableau de tableaux ; il faut encore un map(...) pour le résumer, et .[0] pour lire la clé commune.
Les clés numériques. .[503] += 1 sur un objet est une erreur : les clés d'objet sont des chaînes. .[$s | tostring].
Un filtre qui perd des lignes en silence. select, .[2:], ? et // empty écartent sans bruit. Testez le nombre d'éléments produits, pas seulement leur forme, comme l'a montré lignes_md.
La version de jq. jq 1.8 a changé des comportements : ltrimstr et rtrimstr échouent désormais sur une entrée qui n'est pas une chaîne au lieu de la renvoyer telle quelle, limit avec un nombre négatif est une erreur, indices compte en points de code, tonumber refuse les espaces autour du nombre, --indent 0 n'implique plus -c, d'après le fichier NEWS. Un filtre écrit sur un poste à jour (Homebrew, publications du projet) peut se comporter autrement sur un serveur Debian. Testez sur la version cible, et notez-la dans versions comme le fait ce cours. Notez aussi que le paquet d'Ubuntu 24.04 répond jq-1.7 à jq --version, alors qu'il s'agit de la 1.7.1 : dpkg -s jq donne la version exacte.
Sécurité
Ne jamais coller une variable dans le texte d'un filtre.
jq ".[] | select(.name == \"$nom\")"est une injection : une valeur contenant") | ... | ("réécrit le filtre, peut lire d'autres champs du document, ou$ENVet donc les secrets de l'environnement.--arg nom "$nom"et$nomdans le filtre, comme-vpour awk à la leçon 5. C'est la même famille de failles que l'injection de script dans une CI.Produire du shell avec
@sh, et seulement ainsi. Si un script doit générer des commandes à partir de données JSON,@shcite chaque valeur pour le shell :$ jq -n -r '["sig-app-1", "l'"'"'outil"] | @sh' 'sig-app-1' 'l'\''outil'Mieux encore, évitez de générer du shell : sortez les valeurs séparées par l'octet nul (
--raw-output0, jq 1.7) et lisez-les avecread -d ''(leçon 5 du cours Bash).Le JSON que l'on produit part ailleurs. Le rapport transmis à la mairie ne contient que des agrégats : pas d'IP de client, pas de chemin complet de requête. Les adresses des sources SSH sont des adresses d'attaquants, que l'on garde dans le volet technique, pas dans le fichier destiné à la mairie : un rapport destiné à un tiers se construit en choisissant les champs, jamais en retirant ceux qui gênent d'un document complet (le jour où l'API ajoute un champ, il partirait sans que personne ne l'ait décidé). Le RGPD appelle cela la minimisation.
Les secrets dans les fichiers JSON.
alertes.jsoncontient l'URL du webhook, qui est un secret. Le fichier temporaire créé parmktempa les droits 600, ce qui est bien ; la copie de sauvegarde que l'on serait tenté de faire (cp alertes.json alertes.json.bak) hérite de l'umask, souvent 644, et devient lisible par tous. Etjq . alertes.jsonaffiche le secret dans le terminal, donc potentiellement dans un enregistrement de session :jq 'del(.webhook)'pour le consulter.Les documents hostiles. Un JSON reçu d'internet peut être très profond (des milliers de crochets imbriqués) ou très gros. jq 1.7.1 refuse déjà les documents imbriqués sur plus de 256 niveaux (leçon 7) ; jq 1.8 a porté cette limite à 10 000 et corrigé une série de vulnérabilités, d'après son fichier NEWS. jq 1.7.1 est maintenu par les correctifs de sécurité de Debian et d'Ubuntu (en octobre 2026, le paquet
1.7.1-6+deb13u3de Debian 13 intègre plus de vingt correctifs de CVE, dont ceux publiés avec jq 1.8.2), d'où l'importance des mises à jour automatiques. Bornez la taille de ce que vous acceptez (curl --max-filesize), et lancez le traitement sous un compte sans privilège.-eet l'absence. Un script qui décide d'une action de sécurité (bloquer une IP, révoquer un accès) sur la sortie de jq doit distinguer « faux » de « champ absent » :-erenvoie 1 pourfalseet pournull. Un champ renommé par l'API ferait passer tous les contrôles pour négatifs. Testez explicitement la présence (has("champ")) quand l'enjeu le justifie.
En production
- Versionner les filtres. Un filtre jq de plus d'une ligne vit dans
lib/*.jq, avec des commentaires, sous Git, et a son test danstests/. Le pipeline de CI lance les tests jq comme il lance ShellCheck et Bats (leçon 12 du cours Bash). Un filtre recopié dans dix scripts, c'est dix filtres à corriger le jour où l'API change. - Un contrat de données, plutôt que du Markdown relu. Ici,
rapport-hebdorelit les tableaux Markdown des scripts awk. Si le rapport devient critique (la mairie en tire des indicateurs officiels, un autre outil consomme le JSON), inversez la conception : les scripts awk produisent du TSV, sans mise en forme, et toute la présentation (Markdown, JSON) se fait au bout. Le TSV est un contrat plus simple à tester, et la mise en forme n'est écrite qu'une fois. - Le rapport sous un minuteur.
rapport-hebdoest lancé le lundi matin par un minuteur systemd sursig-outils, comme les tâches du cours d'administration, avecEnvironment=RAPPORT_DONNEES=/srv/donnees. Le JSON est déposé pour la mairie (leçon 9 du cours Bash pour l'envoi fiable), le Markdown part à l'équipe. Le code de sortie non nul déclencheOnFailure=. - Des alertes sur des conditions, pas sur des rapports. Le webhook construit plus haut n'est qu'un dépannage : l'incident de mercredi aurait dû être détecté pendant qu'il durait, par une métrique de taux d'erreurs et une alerte, pas lundi suivant par un rapport. Le cours Prometheus (à venir) remplacera cette surveillance artisanale. Le rapport garde son rôle : une synthèse, des tendances, une trace pour la mairie.
- Les journaux JSON sont là pour être lus par des machines. L'équipe a vu le contraste dans ce cours : les journaux texte demandent awk, des numéros de champs et de la prudence ; les journaux JSON se lisent par nom de champ, et l'ajout d'un champ ne casse rien. Pour toute nouvelle application, journalisez en JSON, une ligne par événement, avec un horodatage ISO 8601 en UTC et des noms de champs stables. Le cours Journaux centralisés avec Loki (à venir) montrera ce que l'on gagne à les centraliser.
- Épingler les versions. Dans une image de conteneur ou une CI, installez jq depuis les paquets de la distribution de base, de la même version que sur les serveurs, ou épinglez une version précise. Un filtre testé avec jq 1.8 sur un poste et exécuté avec 1.7.1 en production est une source de différences subtiles (
ltrimstr,limit,indices).
Exercices
1. Construire (niveau 100). À partir de scw/serveurs.json, produisez (a) un tableau des noms des serveurs arrêtés ; (b) un objet qui associe à chaque nom de serveur son IP privée ; (c) la liste des zones sans doublon.
Solution
jq -c 'map(select(.state == "stopped") | .name)' scw/serveurs.json
# ["sig-app-3"]
jq -c 'map({(.name): .private_nics[0].ip}) | add' scw/serveurs.json
# {"sig-app-1":"172.16.8.11","sig-app-2":"172.16.8.12","sig-outils":"172.16.8.30","sig-app-3":"172.16.8.13"}
jq -c 'map(.zone) | unique' scw/serveurs.json
# ["fr-par-1","fr-par-2"]Pour (b), on peut aussi écrire map({key: .name, value: .private_nics[0].ip}) | from_entries. Les parenthèses autour de .name dans {(.name): ...} sont indispensables : {.name: ...} est une erreur de syntaxe, et jq le dit (May need parentheses around object key expression) ; {name: ...} sans point donnerait la clé littérale name.
2. Prévoir une affectation (niveau 100). Sans lancer jq, donnez la sortie de chaque commande, puis vérifiez.
(a) jq -n -c '{n: 1} | .n += 1' ; (b) jq -n -c '{n: 1, m: 5} | .n = .m' ; (c) jq -n -c '{n: 1, m: 5} | .n |= .m' ; (d) jq -n -c '{a: {b: 2}} | .a.c //= 3 | .a.b //= 9' ; (e) jq -n -c --arg x 10 '{v: $x} | .v > 9'.
Solution
(a) {"n":2}. (b) {"n":5,"m":5} : le côté droit de = est lu sur l'entrée entière. (c) Une erreur : Cannot index number with string "m", code 5. Le côté droit de |= est évalué sur la valeur actuelle de .n, le nombre 1, et l'on ne peut pas lire le champ m d'un nombre. (d) {"a":{"b":2,"c":3}} : c absent reçoit 3, b existant garde 2. (e) true, mais pour une mauvaise raison : $x est la chaîne "10", et une chaîne est toujours plus grande qu'un nombre. Avec --arg x 1, le résultat serait aussi true. --argjson x 10 donne une vraie comparaison numérique.
3. Le taux d'erreurs par hôte et par heure (niveau 200). À partir des journaux JSON, produisez pour le mercredi 7 octobre, pour chaque hôte et chaque heure où il y a eu au moins une erreur 5xx, un objet {hote, heure, requetes, erreurs, taux_pct}, avec un taux arrondi à deux décimales. Utilisez -n et inputs.
Solution
jq -n -c '
def taux($n; $total): if $total == 0 then 0 else (10000 * $n / $total | round) / 100 end;
[inputs
| select(has("requete") and (.horodatage | startswith("2026-10-07")))
| {hote, heure: .horodatage[11:13], erreur: (.requete.statut >= 500)}]
| group_by([.hote, .heure])
| map({hote: .[0].hote, heure: .[0].heure, requetes: length,
erreurs: (map(select(.erreur)) | length)})
| map(select(.erreurs > 0) | .taux_pct = taux(.erreurs; .requetes))
| .[]' journaux/*/api.jsonlSur le bac à sable :
{"hote":"sig-app-1","heure":"06","requetes":6,"erreurs":1,"taux_pct":16.67}
{"hote":"sig-app-2","heure":"14","requetes":160,"erreurs":101,"taux_pct":63.13}
group_by([.hote, .heure]) groupe selon une clé composée : un tableau se compare élément par élément. La fonction taux évite la division par zéro, et round sur 10 000 fois le rapport, divisé par 100, arrondit à deux décimales. La deuxième ligne est l'incident : 63 % d'erreurs sur l'heure de 14 h. La première est une leçon de prudence : une seule erreur 500 sur six requêtes à 6 h du matin donne un taux de 17 %, spectaculaire et sans signification. Un taux se lit toujours avec son volume ; une alerte sérieuse exige un nombre minimal de requêtes avant de se déclencher.
4. Modifier sans casser (niveau 200). Écrivez une fonction Bash json_maj FICHIER FILTRE [ARGS_JQ...] qui applique un filtre jq à un fichier en place, sans jamais le vider ni changer ses droits, qui refuse de remplacer le fichier si le résultat n'est pas un document JSON unique, et qui supprime son fichier temporaire en cas d'interruption. Utilisez-la pour passer p95_ms à la valeur d'une variable Bash.
Solution
json_maj() {
local fichier=$1 filtre=$2 tmp
shift 2
tmp=$(mktemp -- "$fichier.XXXXXX") || return 1
if ! jq "$@" -- "$filtre" "$fichier" > "$tmp" ||
! jq -e -s 'length == 1' "$tmp" > /dev/null; then
printf 'json_maj : %s inchangé (filtre en échec ou sortie multiple)\n' "$fichier" >&2
rm -f -- "$tmp"
return 1
fi
chmod --reference="$fichier" -- "$tmp"
mv -- "$tmp" "$fichier"
}$ seuil=250
$ json_maj alertes.json '.seuils.p95_ms = $s' --argjson s "$seuil"
$ jq -c .seuils alertes.json; stat -c '%a' alertes.json
{"taux_5xx_pct":5,"p95_ms":250}
640
$ json_maj alertes.json '.seuils[]' || echo "code $?"
json_maj : alertes.json inchangé (filtre en échec ou sortie multiple)
code 1
- Les arguments supplémentaires (
--argjson s ...) passent à jq par"$@", avant--, qui marque la fin des options : un filtre commençant par-ne sera pas pris pour une option. - La vérification
jq -e -s 'length == 1'protège contre un filtre comme.seuils[], qui produirait plusieurs documents et transformerait le fichier en un flux qu'aucun lecteur n'attend. - En cas d'échec, le temporaire est supprimé et l'original n'a pas bougé. Pour couvrir aussi une interruption par un signal entre
mktempetmv, le script appelant pose un piègeEXITqui supprime les temporaires connus (leçon 10 du cours Bash) ; un piège posé dans la fonction elle-même resterait actif après son retour, avec une variable locale disparue. - En
root, ajoutezchown --reference="$fichier"pour conserver aussi le propriétaire.
Comparez avec sed -i à la leçon 4 : c'est la même mécanique, écrite à la main.
5. Choisir son outil (niveau 200). Pour chaque tâche, choisissez l'outil (ou la combinaison) et justifiez en une phrase : (a) vérifier dans un script si le mot FATAL apparaît dans un journal de 5 Go ; (b) remplacer delai = 30 par delai = 60 dans la section [api] de app.conf sur deux serveurs ; (c) calculer le nombre de signalements par commune et par type sur un an d'exports CSV, avec des communes entre guillemets ; (d) extraire, d'un manifeste Kubernetes YAML, les images de tous les conteneurs ; (e) produire le corps JSON d'une requête de création de ticket contenant un message d'erreur PostgreSQL ; (f) trouver, sur trois ans de journaux JSON de dix serveurs, les clients qui ont reçu des erreurs sur au moins trois jours différents.
Solution
(a) grep -q -F FATAL : arrêt à la première occurrence, code de sortie direct, aucune autre lecture nécessaire. (b) sed -i avec une adresse d'intervalle de section, ou mieux un outil de gestion de configuration (leçon 4) ; jq et awk sont hors sujet sur un fichier INI. (c) Miller (mlr --icsv count-distinct -f commune,type) ou Python avec le module csv : les guillemets de la RFC 4180 excluent awk -F,. (d) yq, en vérifiant lequel est installé : avec le yq Python, la syntaxe est celle de jq (yq -r '.. | .image? // empty'). (e) jq -n --arg : seul un outil qui connaît JSON échappe correctement guillemets et sauts de ligne. (f) Une base : DuckDB qui lit directement les fichiers JSON Lines, ou un import dans SQLite ; la requête (grouper par client, compter les jours distincts, filtrer) s'écrit en trois lignes de SQL, alors que jq chargerait des gigaoctets et qu'awk n'aurait pas la notion de JSON.
Récapitulatif
- Construire est encore filtrer :
[f]collecte les sorties,{cle: f}construit un objet,{(expr): v}calcule une clé ; une valeur à plusieurs sorties multiplie les objets. group_by,sort_by,unique_by,min_by,max_by,add,any,alltravaillent sur des tableaux ;group_bytrie, et.[0]lit la clé d'un groupe.reduce SOURCE as $x (INIT; MAJ)accumule un état ; parfait pour compter, lent pour collecter des listes.to_entries,from_entries,with_entriestraitent un objet comme une liste de paires ;paths,getpath,delmanipulent des emplacements.=évalue le côté droit sur l'entrée entière,|=sur la valeur à modifier ;+=et//=(valeur par défaut, idempotente) complètent.- Pour les gros flux :
-netinputs, avec une projection précoce ;-scoûte environ dix fois la taille des données en mémoire ;--streampour un document unique énorme. --argdonne une chaîne,--argjsonune valeur JSON,--slurpfileun tableau de documents,--rawfileun texte,-Rdes lignes brutes.- Jamais de JSON assemblé par
printf:jq -n --arg. Jamais dejq ... f > f: temporaire du même répertoire, droits recopiés,mv. - Les filtres de plus d'une ligne vont dans
lib/*.jq, avec des tests qui vérifient aussi le nombre d'éléments produits. - jq 1.7.1 sur Debian 13 et Ubuntu 24.04, jq 1.8 en amont :
@urid,trimet des changements de comportement. Testez sur la version cible. - Choisir son outil : grep pour trouver, sed pour substituer, awk pour les champs simples, jq pour le JSON, yq pour le YAML (en sachant lequel), Miller pour le CSV, SQL pour les gros volumes et les jointures, Python pour la logique.
Pour aller plus loin
- Le manuel de jq 1.7, en particulier les sections Reduce, Assignment, Paths, Streaming et Regular expressions, et la page jq Language Description du wiki, qui décrit la sémantique des générateurs et des chemins plus précisément que le manuel.
- Le fichier NEWS de jq 1.8, à lire avant de faire tourner un filtre sur une version plus récente que celle de vos serveurs.
- La syntaxe d'Oniguruma, pour les expressions régulières de
test,captureetsub. - La documentation de Miller, l'outil à apprendre si vous traitez beaucoup de CSV, et celles des deux yq, Python et Go, pour savoir lequel vous utilisez.
- Les RFC 8259 (JSON) et 7464 (séquences de textes JSON, l'option
--seqde jq), et la description de JSON Lines. - Pour valider le cours : le quiz (niveau 100), puis le lab (niveau 200), qui fait reconstruire
rapport-hebdoet ses bibliothèques, puis les vérifie de l'extérieur sur un jeu de données que vous ne connaissez pas d'avance.
Sources
- jq 1.7 Manual (constructions, reduce, chemins, affectations, entrées, expressions régulières, dates)
- jq Language Description, wiki du projet jqlang
- jq 1.8.2, fichier NEWS (changements de 1.8.0 : @urid, trim, ltrimstr, limit, --indent 0)
- Oniguruma, syntaxe des expressions régulières (doc/RE)
- IETF, RFC 8259 : The JavaScript Object Notation (JSON) Data Interchange Format
- IETF, RFC 7464 : JavaScript Object Notation (JSON) Text Sequences
- JSON Lines, spécification du format
- Miller 6, documentation
- yq (Python, Andrey Kislyuk), documentation
- yq (Go, Mike Farah), documentation
- moreutils (sponge), Joey Hess
- Mattermost, Incoming webhooks
- DuckDB, lecture de JSON
- Debian, paquet miller (trixie)