Winston : journalisation structurée et corrélations

Dans une API Node.js, des messages libres écrits dans stdout deviennent vite difficiles à exploiter. Un journal utile ressemble plutôt à une suite d’événements JSON, chacun relié à une opération précise et à ses composants distants.

Construire un système de journalisation structurée avec Winston et des corrélations consiste à normaliser les données, propager un identifiant de contexte et conserver le même schéma dans tous les services. Le résultat permet de relier une erreur à une commande, de mesurer un traitement et de réduire le temps d’enquête.

Définir un contrat d’événement

Le point de départ est un contrat commun : horodatage UTC, niveau, nom de l’événement, message, service, environnement et identifiants de corrélation. Les champs métier, comme orderId, restent séparés du texte libre afin de pouvoir les indexer et les filtrer.

Pour approfondir les conventions Node.js, les tutoriels DéveloppeurWeb.Com donnent des repères utiles avant de choisir les noms de champs et les niveaux de gravité.

Avant d’écrire du code, identifiez les événements qui doivent exister : commande reçue, paiement accepté, facture émise ou traitement échoué. Cette liste stable évite de multiplier des variantes presque identiques et donne aux tableaux de bord une base commune.

Configurer Winston

Winston sépare le logger, les formats et les destinations. Le paquet s’installe avec npm install winston, puis un logger JSON produit une sortie directement exploitable par les collecteurs :

import winston from 'winston';

const { combine, errors, json, timestamp } = winston.format;

export const logger = winston.createLogger({
  level: process.env.LOG_LEVEL ?? 'info',
  format: combine(
    errors({ stack: true }),
    timestamp({ format: 'isoDateTime' }),
    json()
  ),
  defaultMeta: {
    service: 'api-commandes',
    environment: process.env.NODE_ENV ?? 'development'
  },
  transports: [new winston.transports.Console()]
});

Chaque appel fusionne ses métadonnées avec celles du logger. Ainsi, logger.info('Commande acceptée', { orderId, total }) produit une seule ligne JSON, tandis que la pile d’appels reste disponible lorsqu’une erreur est journalisée.

Pour un service autonome, le transport Http envoie les événements par lots vers un collecteur et réessaie les envois échoués. Le cycle requête-réponse ressemble alors à celui d’un serveur HTTP minimal, mais la livraison devient asynchrone et indépendante de la réponse applicative.

Propager une corrélation de bout en bout

Un correlationId identifie un traitement métier à travers plusieurs composants. Il doit être créé à l’entrée du système, validé s’il vient d’un en-tête, puis transmis dans les requêtes, les messages et les événements.

import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

export const context = new AsyncLocalStorage();

export function attachCorrelation(req, res, next) {
  const received = req.get('x-correlation-id');
  const correlationId = /^[a-zA-Z0-9._-]{1,128}$/.test(received ?? '')
    ? received
    : randomUUID();

  res.set('x-correlation-id', correlationId);
  context.run({ correlationId }, () => next());
}

AsyncLocalStorage conserve le contexte pendant les opérations asynchrones sans imposer son passage manual dans chaque fonction. Une route Express peut lire context.getStore() et ajouter le même identifiant à tous ses journaux.

Les consommateurs de files doivent recopier cet identifiant dans les métadonnées du message. Dans l’écosystème open source, des discussions comme celles de l’émission Ubuntu Podcast illustrent l’intérêt de standards interopérables pour les outils d’exploitation.

Normaliser les événements et les niveaux

Chaque ligne doit permettre de retrouver rapidement le contexte sans analyser le message humain. Conservez un nom d’événement stable, tel que payment.accepted, et placez les variables dans des propriétés JSON typées plutôt que dans des chaînes interpolées.

Réservez le champ message à une description lisible et level à la gravité opérationnelle. Les niveaux debug, info, warn et error doivent correspondre à des règles partagées : une information attendue ne déclenche pas d’alerte, tandis qu’un échec métier critique justifie une notification.

Ne confondez pas corrélation et traçage distribué. Le correlationId regroupe les événements d’une même opération métier, tandis qu’un traceId relie les portions d’une trace OpenTelemetry. Conservez les deux lorsque plusieurs systèmes participent au traitement et ajoutez une version de schéma pour rendre les évolutions compatibles.

Protéger les données sensibles

Les journaux sont souvent copiés dans des outils d’analyse, des sauvegardes ou des environments partagés. Ne journalisez jamais un mot de passe, un jeton d’accès, un cookie, une carte bancaire ou un document d’identité. Les métadonnées doivent suivre une liste d’autorisation, complétée si nécessaire par une transformation qui masque les champs sensibles.

Une règle peut accepter un chemin de démonstration, comme celui de ce casino en ligne, sans conserver les paramètres de requête susceptibles de contenir des identifiants. En cas de doute, enregistrez uniquement l’identifiant de la ressource plutôt que l’URL complète.

Le format JSON échappe les retours à la ligne et les caractères de contrôle, ce qui limite les injections dans les consoles. Prévoyez aussi une politique de conservation : les événements détaillés peuvent être conservés quelques jours, tandis que les métadonnées de diagnostic restent disponibles plus longtemps.

Acheminer, tester et observer

Dans un conteneur, le transport Console écrit sur stdout et laisse l’agent de collecte acheminer les lignes vers Loki, Elasticsearch ou un service compatible. Évitez les fichiers locaux, qui peuvent être perdus lors d’un redéploiement et compliquer la rotation.

Pour Loki, placez service, environment et level dans les labels, mais conservez le correlationId dans le corps JSON. Les labels à forte cardinalité nuisent aux performances. Avec Elasticsearch, un modèle de mapping explicite est préférable à l’indexation automatique de champs libres.

Les tests doivent injecter un logger ou intercepter ses transports afin de vérifier la présence du correlationId et du nom d’événement sans écrire dans la production. Suivez ensuite les taux d’erreur, les temps de traitement et les événements sans corrélation dans vos tableaux de bord.

Généraliser sans casser la production

Déployez le nouveau format par étapes, en commençant par un service peu critique. Comparez les volumes, les coûts d’indexation et la qualité des alertes avant de généraliser. Un module de journalisation partagé, versionné dans un paquet interne, simplifie cette évolution.

Prévoyez aussi un mode de repli : en cas de volume excessif, réduisez les événements debug ou échantillonnez les détails répétitifs, sans eliminer les erreurs et les événements métier essentiels. Documentez la procédure dans un runbook accessible aux équipes de développement et d’exploitation.

Commencez dès maintenant : créez un module logger.js, installez Winston, ajoutez le middleware de corrélation et exécutez un parcours complet qui vérifie les événements JSON émis de l’entrée HTTP jusqu’au consommateur de la file.