Observabilité — 01 — Logs structurés : JSON, corrélation, niveaux

Passer des logs texte aux logs JSON structurés. Niveaux de log, correlation ID, aggregation. Implémentation avec pino (TypeScript) et structlog (Python).

01 — Logs structurés : JSON, corrélation, niveaux

Ce que tu vas apprendre

  • Pourquoi les logs texte sont impossibles à exploiter à l'échelle
  • Le format JSON structuré et ses champs obligatoires
  • Les niveaux de log et quand utiliser chacun
  • Le correlation ID : relier les logs d'une même requête dans un système distribué
  • Implémentation complète avec pino (TypeScript) et structlog (Python)

Prérequis


Logs texte vs logs structurés

Un log texte classique :

[2026-06-01 10:23:41] ERROR Payment failed for order abc-123: card declined after 3 attempts

Lisible par un humain. Inutilisable par une machine. Pour extraire l'orderId, le reason ou le nombre de tentatives, il faut un parser regex — fragile, coûteux, et différent pour chaque service.

Un log structuré en JSON :

json{
  "timestamp": "2026-06-01T10:23:41.123Z",
  "level": "error",
  "event": "payment.failed",
  "orderId": "abc-123",
  "reason": "card_declined",
  "attempts": 3,
  "service": "payment-service",
  "requestId": "req-789xyz"
}

Chaque champ est requêtable directement. Dans Kibana, Loki ou CloudWatch : orderId = "abc-123" AND level = "error". Pas de regex.

Un log structuré est un événement, pas un message.

La distinction compte : un message s'adresse à un humain, un événement s'adresse à une machine qui l'agrège, le filtre et le corrèle.


Les champs obligatoires

Tout log structuré doit contenir au minimum :

typescriptinterface LogEntry {
  timestamp: string;   // ISO 8601 — "2026-06-01T10:23:41.123Z"
  level: "debug" | "info" | "warn" | "error" | "fatal";
  event: string;       // Nom de l'événement — "order.created", "payment.failed"
  service: string;     // Nom du service — "order-api", "payment-worker"
  requestId?: string;  // Correlation ID — relier tous les logs d'une même requête
}

Et selon le contexte, des champs domaine :

json{
  "timestamp": "2026-06-01T10:23:41.123Z",
  "level": "info",
  "event": "order.created",
  "service": "order-api",
  "requestId": "req-abc123",
  "orderId": "ord-456",
  "customerId": "cust-789",
  "totalCents": 4990,
  "durationMs": 45
}

Les niveaux de log

Niveau Quand l'utiliser Exemple
debug Informations de développement — désactivé en prod Paramètres d'une requête SQL
info Événements métier normaux Commande créée, paiement reçu
warn Situation anormale mais récupérée Retry réussi, fallback utilisé
error Erreur qui affecte l'utilisateur mais pas le service Paiement refusé, validation échouée
fatal Erreur qui arrête le service Connexion BDD perdue, port déjà utilisé

Règle clé : ne pas logger en error ce qui est un comportement attendu. Un paiement refusé par la banque est un info ou warn — c'est une réponse normale, pas une erreur système. Un timeout vers la BDD est un error.


Implémentation TypeScript avec pino

pino est le logger Node.js le plus rapide — il sérialise en JSON nativement.

bashnpm install pino pino-http
typescript// logger.ts
import pino from "pino";

export const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  base: {
    service: "order-api",
    version: process.env.APP_VERSION || "unknown",
  },
  timestamp: pino.stdTimeFunctions.isoTime,
  // En développement, activer pretty-print avec pino-pretty
  // En production : JSON brut (plus rapide, agrégé par Loki/ELK)
});

Utilisation dans un handler :

typescript// order.handler.ts
import { logger } from "./logger";
import type { Request, Response } from "express";

export async function createOrder(req: Request, res: Response): Promise<void> {
  const requestLogger = logger.child({
    requestId: req.headers["x-request-id"] ?? crypto.randomUUID(),
    userId: req.user?.id,
  });

  requestLogger.info({ event: "order.create.start", body: req.body });

  try {
    const order = await orderService.create(req.body);

    requestLogger.info({
      event: "order.created",
      orderId: order.id,
      totalCents: order.totalCents,
      durationMs: Date.now() - req.startTime,
    });

    res.status(201).json(order);
  } catch (err) {
    requestLogger.error({
      event: "order.create.failed",
      error: err instanceof Error ? err.message : String(err),
      stack: err instanceof Error ? err.stack : undefined,
    });

    res.status(500).json({ error: "Internal server error" });
  }
}

Middleware HTTP pour pino

typescriptimport pinoHttp from "pino-http";

app.use(pinoHttp({
  logger,
  customProps: (req) => ({
    requestId: req.headers["x-request-id"] ?? crypto.randomUUID(),
  }),
  customSuccessMessage: (req, res) =>
    `${req.method} ${req.url}${res.statusCode}`,
  customErrorMessage: (req, res, err) =>
    `${req.method} ${req.url}${res.statusCode}${err.message}`,
}));

Résultat pour chaque requête HTTP :

json{
  "timestamp": "2026-06-01T10:23:41.123Z",
  "level": "info",
  "requestId": "req-abc123",
  "method": "POST",
  "url": "/orders",
  "statusCode": 201,
  "durationMs": 45,
  "service": "order-api"
}

Implémentation Python avec structlog

bashpip install structlog
python# logger.py
import logging
import structlog
import sys

def configure_logging(level: str = "INFO") -> None:
    logging.basicConfig(
        format="%(message)s",
        stream=sys.stdout,
        level=getattr(logging, level.upper()),
    )

    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,          # Injecte le contexte partagé
            structlog.processors.add_log_level,               # Ajoute "level"
            structlog.processors.TimeStamper(fmt="iso"),       # Ajoute "timestamp"
            structlog.processors.StackInfoRenderer(),
            structlog.processors.JSONRenderer(),               # Sérialise en JSON
        ],
        wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
        context_class=dict,
        logger_factory=structlog.PrintLoggerFactory(),
    )

logger = structlog.get_logger().bind(service="order-api")

Utilisation :

python# order_handler.py
import uuid
import structlog
from structlog.contextvars import bind_contextvars, clear_contextvars

logger = structlog.get_logger()

async def create_order(request: Request) -> Response:
    # Initialiser le contexte de la requête — propagé à tous les logs du handler
    clear_contextvars()
    bind_contextvars(
        request_id=request.headers.get("x-request-id", str(uuid.uuid4())),
        user_id=request.user.id if request.user else None,
    )

    log = logger.bind(event="order.create")
    log.info("start")

    try:
        order = await order_service.create(request.json())

        log.info(
            "success",
            order_id=order.id,
            total_cents=order.total_cents,
        )

        return JSONResponse(order.to_dict(), status_code=201)

    except Exception as exc:
        log.error("failed", error=str(exc), exc_info=True)
        return JSONResponse({"error": "Internal server error"}, status_code=500)

Résultat JSON :

json{
  "event": "order.create",
  "level": "info",
  "timestamp": "2026-06-01T10:23:41.123456Z",
  "service": "order-api",
  "request_id": "req-abc123",
  "user_id": "user-789",
  "order_id": "ord-456",
  "total_cents": 4990
}

Le correlation ID : relier les logs entre services

Dans un système distribué, une requête passe par plusieurs services. Sans corrélation, impossible de retrouver les logs d'une même requête.

Le pattern :

  1. Le premier service (API Gateway ou service d'entrée) génère un requestId unique
  2. Il le transmet aux services appelés via un header HTTP : X-Request-ID: req-abc123
  3. Chaque service loggue avec ce requestId dans ses logs
typescript// Middleware : propager le correlation ID
app.use((req, res, next) => {
  const requestId = req.headers["x-request-id"] as string ?? crypto.randomUUID();
  req.requestId = requestId;
  res.setHeader("X-Request-ID", requestId);
  next();
});

// Appel vers un service downstream
async function callPaymentService(orderId: string, requestId: string): Promise<void> {
  await fetch("http://payment-service/charge", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Request-ID": requestId, // Propagation
    },
    body: JSON.stringify({ orderId }),
  });
}

Résultat : tous les logs avec requestId = "req-abc123" peuvent être filtrés ensemble dans Kibana ou Loki pour reconstituer le parcours complet d'une requête.


Ce qu'il ne faut pas logger

  • Données personnelles (RGPD) : email, nom, adresse, numéro de carte — utiliser un masque ou un hash
  • Secrets : mots de passe, tokens, clés API — jamais dans les logs
  • Objets entiers non maîtrisés : logger.info(req.body) peut exposer des données sensibles
typescript// ❌ Dangereux
logger.info({ body: req.body }); // Peut contenir un mot de passe

// ✅ Sûr
logger.info({
  event: "user.login",
  email: maskEmail(req.body.email), // "a***@exemple.fr"
  ip: req.ip,
});

function maskEmail(email: string): string {
  const [user, domain] = email.split("@");
  return `${user[0]}***@${domain}`;
}

Résumé

Concept Implémentation
Format JSON structuré — pas de texte libre
Champs obligatoires timestamp, level, event, service
Niveaux debug / info / warn / error / fatal — utiliser le bon
Corrélation requestId propagé via X-Request-ID entre services
TypeScript pino + pino-http
Python structlog + bind_contextvars

Étape suivante : 02 — Métriques — compter, mesurer et alerter sur des données agrégées.


Sources

  • Majors, C., Fong-Jones, L., & Miranda, G. (2022). Observability Engineering, Chapitre 3 "Logs". O'Reilly.
  • pino Documentation. getpino.io/
  • structlog Documentation. www.structlog.org/en/stable/
  • W3C. Trace Context. w3.org/TR/trace-context/ (Standard corrélation ID)

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.