Logs structurés : pourquoi le débogage par printf ne passe pas à l'échelle
console.log('user logged in', userId) marche très bien jusqu'au jour où vous avez dix serveurs, mille lignes de log par seconde et un incident pour lequel vous devez retrouver toutes les requêtes d'un client à travers trois services sur les vingt dernières minutes. À ce stade, les logs en texte brut cessent d'être un outil de débogage et deviennent une botte de foin. Les logs structurés sont la solution, et leur adoption ne coûte presque rien.
Le problème des logs sous forme de chaînes
Une ligne de log traditionnelle est une phrase lisible par un humain :
2026-03-17T09:14:02Z User 4821 logged in from 203.0.113.4 after 2 failed attempts
Facile à lire isolément. Presque impossible à interroger à grande échelle. Pour retrouver toutes les connexions depuis une IP donnée sur une semaine de logs, vous écrivez une regex sur du texte libre, en espérant que personne ne reformule jamais cette ligne (ce qui casserait toutes les requêtes enregistrées), et en espérant que les champs dont vous avez besoin (identifiant utilisateur, IP, nombre de tentatives) figuraient seulement à une position analysable.
Logs structurés : journaliser des données, pas des phrases
Une ligne de log structurée est un enregistrement analysable par machine, presque toujours en JSON, où chaque élément de contexte est un champ à part :
logger.info('user_login', {
userId: 4821,
ip: '203.0.113.4',
failedAttempts: 2,
});
{"level":"info","event":"user_login","userId":4821,"ip":"203.0.113.4","failedAttempts":2,"timestamp":"2026-03-17T09:14:02.118Z"}
Désormais, « toutes les connexions depuis cette IP » est une requête sur le champ ip, et non une regex sur de la prose. « Tous les événements de l'utilisateur 4821 dans la dernière heure » est un filtre, pas un grep. Le nom d'événement (user_login) reste stable même si vous décidez plus tard de reformuler ce que dirait une version lisible : il n'y a pas de version lisible générée à l'écriture ; la mise en forme est une affaire d'affichage, gérée plus tard par ce qui lit les logs.
Un logger structuré minimal
Pas besoin d'un framework lourd pour obtenir l'essentiel du bénéfice. Voici un logger qui ajoute des champs de base cohérents et émet toujours du JSON :
function createLogger(baseFields = {}) {
const emit = (level, event, fields = {}) => {
const record = {
level,
event,
timestamp: new Date().toISOString(),
...baseFields,
...fields,
};
process.stdout.write(JSON.stringify(record) + '\n');
};
return {
info: (event, fields) => emit('info', event, fields),
warn: (event, fields) => emit('warn', event, fields),
error: (event, fields) => emit('error', event, fields),
child: (extraFields) => createLogger({ ...baseFields, ...extraFields }),
};
}
const logger = createLogger({ service: 'auth-api' });
logger.info('user_login', { userId: 4821, ip: '203.0.113.4' });
child() compte plus qu'il n'y paraît : il permet d'attacher un contexte une fois pour toutes et de le faire circuler dans tous les appels suivants, au lieu de répéter les champs partout.
Contexte limité à la requête : le vrai bénéfice
Le vrai gain apparaît quand chaque ligne de log d'une même requête porte automatiquement un identifiant de requête partagé, sans que chaque site d'appel doive le transmettre explicitement.
import { AsyncLocalStorage } from 'node:async_hooks';
const requestContext = new AsyncLocalStorage();
function withRequestContext(requestId, fn) {
return requestContext.run({ requestId }, fn);
}
function requestLogger(event, fields = {}) {
const context = requestContext.getStore() ?? {};
logger.info(event, { ...context, ...fields });
}
// In middleware, per incoming request:
app.use((req, res, next) => {
withRequestContext(crypto.randomUUID(), next);
});
// Anywhere deeper in the call stack, no requestId parameter needed:
requestLogger('db_query_start', { table: 'orders' });
requestLogger('db_query_end', { table: 'orders', durationMs: 12 });
Chaque ligne de log émise n'importe où pendant la durée de vie de cette requête (trois services plus loin, dans un callback, dans un gestionnaire d'erreurs) porte le même requestId. C'est ce qui transforme « retrouver tout ce qui s'est passé pendant cette requête en échec » d'un projet d'archéologie en un simple filtre.
Ce qui a sa place dans un champ de log
- Des identifiants, pas des descriptions.
userId: 4821, et non"for user number 4821". Laissez le lecteur faire la jointure avec votre table d'utilisateurs s'il a besoin du nom. - Des durées sous forme de nombres, dans une seule unité cohérente.
durationMs: 340, toujours en millisecondes, sans jamais mélanger"340ms"et2.1(secondes) selon les sites d'appel. - Des noms d'événements stables.
user_login, et non une phrase dont la formulation change d'un commit à l'autre. Traitez le nom d'événement comme une API : le renommer casse tous les tableaux de bord et alertes construits dessus. - Des erreurs sous forme d'objets structurés, et pas seulement leur
.message:
logger.error('payment_failed', {
orderId: order.id,
error: {
message: err.message,
name: err.name,
stack: err.stack,
},
});
Ce que cela ne remplace pas
Les logs structurés ne remplacent ni les métriques ni les traces : c'est le troisième pilier de l'observabilité, et le mieux adapté à « que s'est-il passé exactement, dans l'ordre, pour cette requête précise ». Les métriques répondent à « combien » dans le temps, à bas coût ; les traces répondent à « où est passé le temps » à travers les services. Les logs répondent à « quelles étaient les valeurs réelles en jeu ». Utilisez ce qui répond à la question que vous posez vraiment : pour la requête échouée d'un client précis, ce sont presque toujours les logs.
À retenir
Passer de logs en chaînes à des logs structurés est un petit changement à l'écriture (passer un objet au lieu de construire une phrase) et un changement énorme à la lecture, car chaque champ devient interrogeable au lieu d'être enfoui dans de la prose. Ajoutez un contexte limité à la requête par-dessus, et vous obtenez ce que le débogage par printf ne pourra jamais offrir : extraire toutes les lignes liées à une requête, au milieu du flot de toutes les autres, en une seule requête.