Aller au contenu
·35 min de lecture

Observabilité en Node.js : logs, métriques et traces sans la complexité

Logging structuré avec Pino, métriques avec Prometheus, tracing distribué avec OpenTelemetry. La stack d'observabilité que j'utilise pour mes apps Node.js en production, de zéro à la visibilité complète.

Partager:X / TwitterLinkedIn

Je pensais que l'observabilité voulait dire « ajouter quelques console.logs et les vérifier quand quelque chose casse ». Ça marchait jusqu'au jour où ça n'a plus marché. Le point de rupture a été un incident de production où notre API retournait des 200 mais les données étaient périmées. Pas d'erreurs dans les logs. Pas d'exceptions. Juste des réponses silencieusement incorrectes parce qu'un cache en aval était devenu obsolète et personne ne l'avait remarqué pendant quatre heures.

C'est là que j'ai appris la différence entre le monitoring et l'observabilité. Le monitoring te dit que quelque chose ne va pas. L'observabilité te dit pourquoi ça ne va pas. Et l'écart entre les deux, c'est là que vivent les incidents de production.

Voici la stack d'observabilité sur laquelle je me suis arrêté pour les services Node.js après avoir essayé la plupart des alternatives. Ce n'est pas le setup le plus sophistiqué du monde, mais il détecte les problèmes avant que les utilisateurs ne les remarquent, et quand quelque chose passe à travers les mailles du filet, je peux diagnostiquer en minutes au lieu d'heures.

Les trois piliers, et pourquoi tu as besoin des trois#

Tout le monde parle des « trois piliers de l'observabilité » — logs, métriques et traces. Ce que personne ne te dit, c'est que chaque pilier répond à une question fondamentalement différente, et tu as besoin des trois parce qu'aucun pilier seul ne peut répondre à toutes les questions.

Les logs répondent à : Que s'est-il passé ?

Une ligne de log dit « à 14:23:07, l'utilisateur 4821 a demandé /api/orders et a reçu un 500 parce que la connexion à la base de données a expiré ». C'est un récit. Ça te raconte l'histoire d'un événement spécifique.

Les métriques répondent à : Combien ça se passe ?

Une métrique dit « sur les 5 dernières minutes, le temps de réponse p99 était de 2.3 secondes et le taux d'erreur de 4.7% ». Ce sont des données agrégées. Ça te parle de la santé du système dans son ensemble, pas d'une requête individuelle.

Les traces répondent à : Où est passé le temps ?

Une trace dit « cette requête a passé 12ms dans le middleware Express, 3ms à parser le body, 847ms à attendre PostgreSQL, et 2ms à sérialiser la réponse ». C'est une cascade. Ça te dit exactement où est le goulot d'étranglement, à travers les frontières de services.

Voici l'implication pratique : quand ton pager sonne à 3h du matin, la séquence est presque toujours la même.

  1. Les métriques te disent que quelque chose ne va pas (pic du taux d'erreur, augmentation de la latence)
  2. Les logs te disent ce qui se passe (messages d'erreur spécifiques, endpoints affectés)
  3. Les traces te disent pourquoi (quel service ou requête de base de données en aval est le goulot d'étranglement)

Si tu n'as que les logs, tu sauras quoi a cassé mais pas à quel point c'est grave. Si tu n'as que les métriques, tu sauras à quel point mais pas ce qui cause le problème. Si tu n'as que les traces, tu auras de belles cascades mais aucun moyen de savoir quand les regarder.

Construisons chaque pilier.

Logging structuré avec Pino#

Pourquoi console.log ne suffit pas#

Je sais. Tu utilises console.log en production et c'est « bien ». Laisse-moi te montrer pourquoi ça ne l'est pas.

typescript
// Ce que tu écris
console.log("User login failed", email, error.message);
 
// Ce qui finit dans ton fichier de logs
// User login failed john@example.com ECONNREFUSED
 
// Maintenant essaie de :
// 1. Chercher tous les échecs de connexion dans la dernière heure
// 2. Compter les échecs par utilisateur
// 3. Filtrer seulement les erreurs ECONNREFUSED
// 4. Corréler ça avec la requête qui l'a déclenché
// Bonne chance. C'est une chaîne non structurée. Tu fais du grep dans du texte.

Le logging structuré signifie que chaque entrée de log est un objet JSON avec des champs cohérents. Au lieu d'une chaîne lisible par l'humain mais hostile pour la machine, tu obtiens un objet lisible par la machine qui est aussi lisible par l'humain (avec les bons outils).

typescript
// Ce à quoi ressemble le logging structuré
{
  "level": 50,
  "time": 1709312587000,
  "msg": "User login failed",
  "email": "john@example.com",
  "error": "ECONNREFUSED",
  "requestId": "req-abc-123",
  "route": "POST /api/auth/login",
  "responseTime": 1247,
  "pid": 12345
}

Maintenant tu peux requêter ça. level >= 50 AND msg = "User login failed" AND time > now() - 1h te donne exactement ce dont tu as besoin.

Pino vs Winston#

J'ai utilisé les deux extensivement. Voici la version courte :

Winston est plus populaire, plus flexible, a plus de transports, et est significativement plus lent. Il encourage aussi les mauvais patterns — le système de « format » rend trop facile la création de logs non structurés, joliment formatés qui sont beaux en développement mais impossibles à parser en production.

Pino est plus rapide (5-10x dans les benchmarks), opinioné sur la sortie JSON, et suit la philosophie Unix : faire une chose bien (écrire du JSON sur stdout) et laisser d'autres outils gérer le reste (transport, formatage, agrégation).

J'utilise Pino. La différence de performance compte quand tu logues des milliers de requêtes par seconde, et l'approche opinionée fait que chaque développeur de l'équipe produit des logs cohérents.

Configuration basique de Pino#

typescript
// src/lib/logger.ts
import pino from "pino";
 
const isProduction = process.env.NODE_ENV === "production";
 
export const logger = pino({
  level: process.env.LOG_LEVEL || (isProduction ? "info" : "debug"),
  // En production, juste du JSON sur stdout. PM2/runtime de conteneur gère le reste.
  // En développement, utiliser pino-pretty pour une sortie lisible.
  ...(isProduction
    ? {}
    : {
        transport: {
          target: "pino-pretty",
          options: {
            colorize: true,
            translateTime: "HH:MM:ss",
            ignore: "pid,hostname",
          },
        },
      }),
  // Champs standards sur chaque ligne de log
  base: {
    service: process.env.SERVICE_NAME || "api",
    version: process.env.APP_VERSION || "unknown",
  },
  // Sérialiser correctement les objets Error
  serializers: {
    err: pino.stdSerializers.err,
    error: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
  // Masquer les champs sensibles
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "password",
      "creditCard",
      "ssn",
    ],
    censor: "[REDACTED]",
  },
});

L'option redact est critique. Sans elle, tu finiras par loguer un mot de passe ou une clé API. Ce n'est pas une question de si, mais de quand. Un développeur ajoutera logger.info({ body: req.body }, "incoming request") et soudain tu logues des numéros de carte de crédit. La rédaction est ton filet de sécurité.

Niveaux de log : les utiliser correctement#

typescript
// FATAL (60) - Le processus va crasher. Réveillez quelqu'un.
logger.fatal({ err }, "Unrecoverable database connection failure");
 
// ERROR (50) - Quelque chose a échoué qui n'aurait pas dû. Investiguer bientôt.
logger.error({ err, userId, orderId }, "Payment processing failed");
 
// WARN (40) - Quelque chose d'inattendu mais géré. Garder un œil dessus.
logger.warn({ retryCount: 3, service: "email" }, "Retry limit approaching");
 
// INFO (30) - Opérations normales qui valent la peine d'être enregistrées.
logger.info({ userId, action: "login" }, "User authenticated");
 
// DEBUG (20) - Informations détaillées pour le debugging. Jamais en production.
logger.debug({ query, params }, "Database query executing");
 
// TRACE (10) - Extrêmement détaillé. Seulement quand tu es désespéré.
logger.trace({ headers: req.headers }, "Incoming request headers");

La règle : si tu hésites entre INFO et DEBUG, c'est DEBUG. Si tu hésites entre WARN et ERROR, demande-toi : « Est-ce que je voudrais être alerté pour ça à 3h du matin ? » Si oui, ERROR. Si non, WARN.

Child loggers et contexte de requête#

C'est là que Pino brille vraiment. Un child logger hérite de toute la configuration du parent mais ajoute des champs de contexte supplémentaires.

typescript
// Chaque log de ce child logger inclura userId et sessionId
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
 
userLogger.info("User viewed dashboard");
// La sortie inclut userId et sessionId automatiquement
 
userLogger.info({ page: "/settings" }, "User navigated");
// La sortie inclut userId, sessionId, ET page

Pour les serveurs HTTP, tu veux un child logger par requête pour que chaque ligne de log dans le cycle de vie de cette requête inclue l'identifiant de requête :

typescript
// src/middleware/request-logger.ts
import { randomUUID } from "node:crypto";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
 
export function requestLogger(req: Request, res: Response, next: NextFunction) {
  const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
  const startTime = performance.now();
 
  // Attacher un child logger à la requête
  req.log = logger.child({
    requestId,
    method: req.method,
    url: req.originalUrl,
    userAgent: req.headers["user-agent"],
    ip: req.headers["x-forwarded-for"]?.toString().split(",").pop()?.trim()
        || req.socket.remoteAddress,
  });
 
  // Définir le header d'identifiant de requête sur la réponse pour la corrélation
  res.setHeader("x-request-id", requestId);
 
  req.log.info("Request received");
 
  res.on("finish", () => {
    const duration = Math.round(performance.now() - startTime);
    const logMethod = res.statusCode >= 500 ? "error"
                    : res.statusCode >= 400 ? "warn"
                    : "info";
 
    req.log[logMethod]({
      statusCode: res.statusCode,
      duration,
      contentLength: res.getHeader("content-length"),
    }, "Request completed");
  });
 
  next();
}

AsyncLocalStorage pour la propagation automatique du contexte#

L'approche child logger fonctionne, mais elle nécessite de passer req.log à travers chaque appel de fonction. Ça devient fastidieux. AsyncLocalStorage résout ça — il fournit un store de contexte qui suit le flux d'exécution asynchrone sans passage explicite.

typescript
// src/lib/async-context.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { logger } from "./logger";
import type { Logger } from "pino";
 
interface RequestContext {
  requestId: string;
  logger: Logger;
  userId?: string;
  startTime: number;
}
 
export const asyncContext = new AsyncLocalStorage<RequestContext>();
 
// Obtenir le logger contextuel depuis n'importe où dans la pile d'appels
export function getLogger(): Logger {
  const store = asyncContext.getStore();
  return store?.logger || logger;
}
 
export function getRequestId(): string | undefined {
  return asyncContext.getStore()?.requestId;
}
typescript
// src/middleware/async-context-middleware.ts
import { randomUUID } from "node:crypto";
import { asyncContext } from "../lib/async-context";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
 
export function asyncContextMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
  const requestLogger = logger.child({ requestId });
 
  const context = {
    requestId,
    logger: requestLogger,
    startTime: performance.now(),
  };
 
  asyncContext.run(context, () => {
    res.setHeader("x-request-id", requestId);
    next();
  });
}

Maintenant n'importe quelle fonction, n'importe où dans la pile d'appels, peut obtenir le logger scopé à la requête :

typescript
// src/services/order-service.ts
import { getLogger } from "../lib/async-context";
 
export async function processOrder(orderId: string) {
  const log = getLogger(); // A automatiquement requestId attaché !
 
  log.info({ orderId }, "Processing order");
 
  const items = await fetchOrderItems(orderId);
  log.debug({ itemCount: items.length }, "Order items fetched");
 
  const total = calculateTotal(items);
  log.info({ orderId, total }, "Order processed successfully");
 
  return { orderId, total, items };
}
 
// Pas besoin de passer le logger en paramètre. Ça marche tout seul.

Agrégation de logs : où vont les logs ?#

En développement, les logs vont sur stdout et pino-pretty les rend lisibles. En production, c'est plus nuancé.

La voie PM2#

Si tu tournes sur un VPS avec PM2 (que j'ai couvert dans mon article sur la configuration VPS), PM2 capture stdout automatiquement :

bash
# Voir les logs en temps réel
pm2 logs api --lines 100
 
# Les logs sont stockés dans ~/.pm2/logs/
# api-out.log  — stdout (tes logs JSON)
# api-error.log — stderr (exceptions non attrapées, stack traces)

La rotation de logs intégrée de PM2 empêche les problèmes d'espace disque :

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress true

Envoyer les logs vers Loki ou Elasticsearch#

Pour tout ce qui va au-delà d'un seul serveur, tu as besoin d'une agrégation de logs centralisée. Les deux principales options :

Grafana Loki — Le « Prometheus des logs ». Léger, n'indexe que les labels (pas le texte complet), fonctionne magnifiquement avec Grafana. Ma recommandation pour la plupart des équipes.

Elasticsearch — Recherche plein texte sur les logs. Plus puissant, plus gourmand en ressources, plus de charge opérationnelle. Utilise ça si tu as véritablement besoin de recherche plein texte à travers des millions de lignes de log.

Pour Loki, le setup le plus simple utilise Promtail pour envoyer les logs :

yaml
# /etc/promtail/config.yml
server:
  http_listen_port: 9080
 
positions:
  filename: /tmp/positions.yaml
 
clients:
  - url: http://loki:3100/loki/api/v1/push
 
scrape_configs:
  - job_name: node-api
    static_configs:
      - targets:
          - localhost
        labels:
          job: node-api
          environment: production
          __path__: /home/deploy/.pm2/logs/api-out.log
    pipeline_stages:
      - json:
          expressions:
            level: level
            msg: msg
            service: service
      - labels:
          level:
          service:
      - timestamp:
          source: time
          format: UnixMs

Le format NDJSON#

Pino produit du Newline Delimited JSON (NDJSON) par défaut — un objet JSON par ligne, séparé par \n. C'est important parce que :

  1. Chaque outil d'agrégation de logs le comprend
  2. C'est streamable (tu peux traiter les logs ligne par ligne sans mettre le fichier entier en mémoire)
  3. Les outils Unix standards fonctionnent dessus : cat api-out.log | jq '.msg' | sort | uniq -c | sort -rn

Ne configure jamais Pino pour produire du JSON formaté sur plusieurs lignes en production. Tu casseras chaque outil dans le pipeline.

typescript
// FAUX en production — le JSON multi-ligne casse le traitement ligne par ligne
{
  "level": 30,
  "time": 1709312587000,
  "msg": "Request completed"
}
 
// CORRECT en production — NDJSON, un objet par ligne
{"level":30,"time":1709312587000,"msg":"Request completed"}

Métriques avec Prometheus#

Les logs te disent ce qui s'est passé. Les métriques te disent comment le système se comporte. La différence est comme lire chaque transaction sur ton relevé bancaire versus regarder ton solde.

Les quatre types de métriques#

Prometheus a quatre types de métriques. Comprendre lequel utiliser quand te sauvera des erreurs les plus courantes.

Counter — Une valeur qui ne fait qu'augmenter. Nombre de requêtes, nombre d'erreurs, octets traités. Se remet à zéro au redémarrage.

typescript
// « Combien de requêtes avons-nous servies ? »
const httpRequestsTotal = new Counter({
  name: "http_requests_total",
  help: "Total number of HTTP requests",
  labelNames: ["method", "route", "status_code"],
});

Gauge — Une valeur qui peut monter ou descendre. Connexions actives, taille de la file d'attente, température, utilisation du heap.

typescript
// « Combien de connexions sont actives en ce moment ? »
const activeConnections = new Gauge({
  name: "active_connections",
  help: "Number of currently active connections",
});

Histogram — Observe des valeurs et les compte dans des buckets configurables. Durée de requête, taille de réponse. C'est comme ça qu'on obtient les percentiles (p50, p95, p99).

typescript
// « Combien de temps les requêtes prennent-elles ? » avec des buckets à 10ms, 50ms, 100ms, etc.
const httpRequestDuration = new Histogram({
  name: "http_request_duration_seconds",
  help: "Duration of HTTP requests in seconds",
  labelNames: ["method", "route", "status_code"],
  buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});

Summary — Similaire à Histogram mais calcule les quantiles côté client. Utilise Histogram plutôt, sauf si tu as une raison spécifique. Les Summaries ne peuvent pas être agrégées entre instances.

Configuration complète de prom-client#

typescript
// src/lib/metrics.ts
import {
  Registry,
  Counter,
  Histogram,
  Gauge,
  collectDefaultMetrics,
} from "prom-client";
 
// Créer un registre personnalisé pour ne pas polluer le global
export const metricsRegistry = new Registry();
 
// Collecter les métriques Node.js par défaut :
// - process_cpu_seconds_total
// - process_resident_memory_bytes
// - nodejs_heap_size_total_bytes
// - nodejs_active_handles_total
// - nodejs_eventloop_lag_seconds
// - nodejs_gc_duration_seconds
collectDefaultMetrics({
  register: metricsRegistry,
  prefix: "nodeapp_",
  // Collecter toutes les 10 secondes
  gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
});
 
// --- Métriques HTTP ---
 
export const httpRequestsTotal = new Counter({
  name: "nodeapp_http_requests_total",
  help: "Total number of HTTP requests received",
  labelNames: ["method", "route", "status_code"] as const,
  registers: [metricsRegistry],
});
 
export const httpRequestDuration = new Histogram({
  name: "nodeapp_http_request_duration_seconds",
  help: "Duration of HTTP requests in seconds",
  labelNames: ["method", "route", "status_code"] as const,
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
  registers: [metricsRegistry],
});
 
export const httpRequestSizeBytes = new Histogram({
  name: "nodeapp_http_request_size_bytes",
  help: "Size of HTTP request bodies in bytes",
  labelNames: ["method", "route"] as const,
  buckets: [100, 1000, 10000, 100000, 1000000],
  registers: [metricsRegistry],
});
 
// --- Métriques métier ---
 
export const ordersProcessed = new Counter({
  name: "nodeapp_orders_processed_total",
  help: "Total number of orders processed",
  labelNames: ["status"] as const, // "success", "failed", "refunded"
  registers: [metricsRegistry],
});
 
export const activeWebSocketConnections = new Gauge({
  name: "nodeapp_active_websocket_connections",
  help: "Number of active WebSocket connections",
  registers: [metricsRegistry],
});
 
export const externalApiDuration = new Histogram({
  name: "nodeapp_external_api_duration_seconds",
  help: "Duration of external API calls",
  labelNames: ["service", "endpoint", "status"] as const,
  buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30],
  registers: [metricsRegistry],
});
 
export const dbQueryDuration = new Histogram({
  name: "nodeapp_db_query_duration_seconds",
  help: "Duration of database queries",
  labelNames: ["operation", "table"] as const,
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
  registers: [metricsRegistry],
});

Le middleware de métriques#

typescript
// src/middleware/metrics-middleware.ts
import { httpRequestsTotal, httpRequestDuration } from "../lib/metrics";
import type { Request, Response, NextFunction } from "express";
 
// Normaliser les routes pour éviter l'explosion de cardinalité
// /api/users/123 → /api/users/:id
// Sans ça, Prometheus créera une nouvelle série temporelle pour chaque ID utilisateur
function normalizeRoute(req: Request): string {
  const route = req.route?.path || req.path;
 
  // Remplacer les segments dynamiques courants
  return route
    .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "/:uuid")
    .replace(/\/\d+/g, "/:id")
    .replace(/\/[a-f0-9]{24}/g, "/:objectId");
}
 
export function metricsMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Ne pas tracker les métriques pour l'endpoint de métriques lui-même
  if (req.path === "/metrics") {
    return next();
  }
 
  const end = httpRequestDuration.startTimer();
 
  res.on("finish", () => {
    const route = normalizeRoute(req);
    const labels = {
      method: req.method,
      route,
      status_code: res.statusCode.toString(),
    };
 
    httpRequestsTotal.inc(labels);
    end(labels);
  });
 
  next();
}

L'endpoint /metrics#

typescript
// src/routes/metrics.ts
import { Router } from "express";
import { metricsRegistry } from "../lib/metrics";
 
const router = Router();
 
router.get("/metrics", async (req, res) => {
  // Protection par auth basique — ne pas exposer les métriques publiquement
  const authHeader = req.headers.authorization;
  const expected = `Basic ${Buffer.from(
    `${process.env.METRICS_USER}:${process.env.METRICS_PASSWORD}`
  ).toString("base64")}`;
 
  if (!authHeader || authHeader !== expected) {
    res.status(401).set("WWW-Authenticate", "Basic").send("Unauthorized");
    return;
  }
 
  try {
    const metrics = await metricsRegistry.metrics();
    res.set("Content-Type", metricsRegistry.contentType);
    res.send(metrics);
  } catch (err) {
    res.status(500).send("Error collecting metrics");
  }
});
 
export default router;

Les métriques métier personnalisées sont le vrai pouvoir#

Les métriques Node.js par défaut (taille du heap, lag de l'event loop, durée du GC) sont le minimum. Elles te parlent de la santé du runtime. Mais les métriques métier te parlent de la santé de l'application.

typescript
// Dans ton service de commandes
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
 
export async function processOrder(order: Order) {
  try {
    // Mesurer l'appel au fournisseur de paiement
    const paymentTimer = externalApiDuration.startTimer({
      service: "stripe",
      endpoint: "charges.create",
    });
 
    const charge = await stripe.charges.create({
      amount: order.total,
      currency: "usd",
      source: order.paymentToken,
    });
 
    paymentTimer({ status: "success" });
 
    ordersProcessed.inc({ status: "success" });
    return charge;
  } catch (err) {
    ordersProcessed.inc({ status: "failed" });
 
    externalApiDuration.startTimer({
      service: "stripe",
      endpoint: "charges.create",
    })({ status: "error" });
 
    throw err;
  }
}

Un pic dans ordersProcessed{status="failed"} te dit quelque chose qu'aucune quantité de métriques CPU ne pourra jamais te dire.

Cardinalité des labels : le tueur silencieux#

Un mot d'avertissement. Chaque combinaison unique de valeurs de labels crée une nouvelle série temporelle. Si tu ajoutes un label userId à ton compteur de requêtes HTTP, et que tu as 100 000 utilisateurs, tu viens de créer plus de 100 000 séries temporelles. Prometheus va s'effondrer.

Règles pour les labels :

  • Cardinalité basse uniquement : méthode HTTP (7 valeurs), code de statut (5 catégories), route (des dizaines, pas des milliers)
  • Jamais d'IDs utilisateur, IDs de requête, adresses IP ou horodatages comme valeurs de labels
  • Si tu n'es pas sûr, n'ajoute pas le label. Tu pourras toujours l'ajouter plus tard, mais le supprimer nécessite de modifier les tableaux de bord et les alertes

Tableaux de bord Grafana#

Prometheus stocke les données. Grafana les visualise. Voici les panneaux que je mets sur chaque tableau de bord de service Node.js.

Le tableau de bord essentiel#

1. Taux de requêtes (requêtes/seconde)

promql
rate(nodeapp_http_requests_total[5m])

Montre le pattern de trafic. Utile pour repérer les pics ou les chutes soudaines.

2. Taux d'erreur (%)

promql
100 * (
  sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
  /
  sum(rate(nodeapp_http_requests_total[5m]))
)

Le chiffre le plus important. Si ça dépasse 1%, quelque chose ne va pas.

3. Latence p50 / p95 / p99

promql
histogram_quantile(0.99,
  sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
)

p50 te dit l'expérience typique. p99 te dit la pire expérience. Si p99 est 10x le p50, tu as un problème de latence de queue.

4. Lag de l'event loop

promql
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}

Si ça dépasse 100ms, ton event loop est bloqué. Probablement une opération synchrone dans un chemin asynchrone.

5. Utilisation du heap

promql
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100

Surveille une tendance régulière à la hausse — c'est une fuite mémoire. Les pics pendant le GC sont normaux.

6. Handles actifs

promql
nodeapp_nodejs_active_handles_total

Descripteurs de fichiers, sockets, timers ouverts. Un nombre en croissance continue signifie que tu fuites des handles — probablement des connexions de base de données ou des réponses HTTP non fermées.

Tableau de bord Grafana as code#

Tu peux versionner tes tableaux de bord en utilisant la fonctionnalité de provisioning de Grafana :

yaml
# /etc/grafana/provisioning/dashboards/dashboards.yml
apiVersion: 1
providers:
  - name: "Node.js Services"
    orgId: 1
    folder: "Services"
    type: file
    disableDeletion: false
    editable: true
    options:
      path: /var/lib/grafana/dashboards
      foldersFromFilesStructure: true

Exporte le JSON de ton tableau de bord depuis Grafana, commite-le dans ton repo, et ton tableau de bord survit aux réinstallations de Grafana. Ce n'est pas optionnel pour la production — c'est le même principe que l'infrastructure as code.

Tracing distribué avec OpenTelemetry#

Le tracing est le pilier que la plupart des équipes adoptent en dernier, et celui qu'elles auraient aimé adopter en premier. Quand tu as plusieurs services qui communiquent (même si c'est juste « serveur API + base de données + Redis + API externe »), le tracing te montre l'image complète du parcours d'une requête.

Qu'est-ce qu'une trace ?#

Une trace est un arbre de spans. Chaque span représente une unité de travail — une requête HTTP, une requête de base de données, un appel de fonction. Les spans ont un temps de début, un temps de fin, un statut et des attributs. Ils sont liés ensemble par un trace ID qui se propage à travers les frontières de services.

Trace: abc-123
├── [API Gateway] POST /api/orders (250ms)
│   ├── [Auth Service] validate-token (12ms)
│   ├── [Order Service] create-order (230ms)
│   │   ├── [PostgreSQL] INSERT INTO orders (15ms)
│   │   ├── [Redis] SET order:cache (2ms)
│   │   └── [Payment Service] charge (200ms)
│   │       ├── [Stripe API] POST /v1/charges (180ms)
│   │       └── [PostgreSQL] UPDATE orders SET status (8ms)
│   └── [Email Service] send-confirmation (async, 45ms)

Un seul coup d'œil te dit : la requête de 250ms a passé 180ms à attendre Stripe. C'est là qu'il faut optimiser.

Configuration d'OpenTelemetry#

OpenTelemetry (OTel) est le standard. Il a remplacé le paysage fragmenté de clients Jaeger, clients Zipkin et SDKs spécifiques aux fournisseurs par une API unique et vendor-neutral.

typescript
// src/instrumentation.ts
// Ce fichier DOIT être chargé avant tout autre import.
// En Node.js, utiliser le flag --require ou --import.
 
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { Resource } from "@opentelemetry/resources";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
 
const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: process.env.SERVICE_NAME || "node-api",
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION || "0.0.0",
    "deployment.environment": process.env.NODE_ENV || "development",
  }),
 
  // Envoyer les traces à ton collecteur (Jaeger, Tempo, etc.)
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
  }),
 
  // Optionnellement envoyer les métriques via OTel aussi
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
    }),
    exportIntervalMillis: 15000,
  }),
 
  // Auto-instrumentation : crée automatiquement des spans pour
  // les requêtes HTTP, les routes Express, les requêtes PostgreSQL, les commandes Redis,
  // les lookups DNS, et plus encore
  instrumentations: [
    getNodeAutoInstrumentations({
      // Désactiver les instrumentations bruyantes
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
      // Configurer les spécifiques
      "@opentelemetry/instrumentation-http": {
        ignoreIncomingPaths: ["/health", "/ready", "/metrics"],
      },
      "@opentelemetry/instrumentation-express": {
        ignoreLayersType: ["middleware"],
      },
    }),
  ],
});
 
sdk.start();
 
// Arrêt gracieux
process.on("SIGTERM", () => {
  sdk.shutdown().then(
    () => console.log("OTel SDK shut down successfully"),
    (err) => console.error("Error shutting down OTel SDK", err)
  );
});

Démarre ton application avec :

bash
node --import ./src/instrumentation.ts ./src/server.ts

C'est tout. Sans aucun changement de code applicatif, tu as maintenant des traces pour chaque requête HTTP, chaque requête de base de données, chaque commande Redis.

Création manuelle de spans#

L'auto-instrumentation couvre les appels d'infrastructure, mais parfois tu veux tracer la logique métier :

typescript
// src/services/order-service.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
 
const tracer = trace.getTracer("order-service");
 
export async function processOrder(orderId: string): Promise<Order> {
  return tracer.startActiveSpan("processOrder", async (span) => {
    try {
      span.setAttribute("order.id", orderId);
 
      // Ce span devient le parent de toute requête BD
      // ou appel HTTP auto-instrumenté dans ces fonctions
      const order = await fetchOrder(orderId);
      span.setAttribute("order.total", order.total);
      span.setAttribute("order.item_count", order.items.length);
 
      const validationResult = await tracer.startActiveSpan(
        "validateOrder",
        async (validationSpan) => {
          const result = await validateInventory(order);
          validationSpan.setAttribute("validation.passed", result.valid);
          if (!result.valid) {
            validationSpan.setStatus({
              code: SpanStatusCode.ERROR,
              message: `Validation failed: ${result.reason}`,
            });
          }
          validationSpan.end();
          return result;
        }
      );
 
      if (!validationResult.valid) {
        span.setStatus({
          code: SpanStatusCode.ERROR,
          message: "Order validation failed",
        });
        throw new Error(validationResult.reason);
      }
 
      const payment = await processPayment(order);
      span.setAttribute("payment.id", payment.id);
 
      span.setStatus({ code: SpanStatusCode.OK });
      return order;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: (err as Error).message,
      });
      throw err;
    } finally {
      span.end();
    }
  });
}

Propagation du contexte de trace#

La magie du tracing distribué, c'est que le trace ID suit la requête à travers les services. Quand le Service A appelle le Service B, le contexte de trace est automatiquement injecté dans les headers HTTP (header traceparent selon le standard W3C Trace Context).

L'auto-instrumentation gère ça pour les appels HTTP sortants. Mais si tu utilises une file de messages, tu dois propager manuellement :

typescript
import { context, propagation } from "@opentelemetry/api";
 
// Lors de la publication d'un message
function publishEvent(queue: string, payload: object) {
  const carrier: Record<string, string> = {};
 
  // Injecter le contexte de trace actuel dans le carrier
  propagation.inject(context.active(), carrier);
 
  // Envoyer à la fois le payload et le contexte de trace
  messageQueue.publish(queue, {
    payload,
    traceContext: carrier,
  });
}
 
// Lors de la consommation d'un message
function consumeEvent(message: QueueMessage) {
  // Extraire le contexte de trace du message
  const parentContext = propagation.extract(
    context.active(),
    message.traceContext
  );
 
  // Exécuter le handler dans le contexte extrait
  // Maintenant tous les spans créés ici seront enfants de la trace originale
  context.with(parentContext, () => {
    tracer.startActiveSpan("processEvent", (span) => {
      span.setAttribute("queue.message_id", message.id);
      handleEvent(message.payload);
      span.end();
    });
  });
}

Où envoyer les traces#

Jaeger — L'option open source classique. Bonne interface, facile à lancer en local avec Docker. Stockage long terme limité.

Grafana Tempo — Si tu utilises déjà Grafana et Loki, Tempo est le choix naturel pour les traces. Utilise le stockage objet (S3/GCS) pour une rétention long terme économique.

Grafana Cloud / Datadog / Honeycomb — Si tu ne veux pas gérer l'infrastructure. Plus cher, moins de charge opérationnelle.

Pour le développement local, Jaeger dans Docker est parfait :

yaml
# docker-compose.otel.yml
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"   # Interface Jaeger
      - "4318:4318"     # Récepteur OTLP HTTP
    environment:
      - COLLECTOR_OTLP_ENABLED=true

Endpoints de health check#

Les health checks sont la forme la plus simple d'observabilité et la première chose que tu devrais implémenter. Ils répondent à une seule question : « Est-ce que ce service est capable de servir des requêtes en ce moment ? »

Trois types de health checks#

/health — Santé générale. Le processus tourne-t-il et répond-il ?

/ready — Disponibilité. Ce service peut-il gérer du trafic ? (S'est-il connecté à la base de données ? A-t-il chargé sa configuration ? A-t-il réchauffé son cache ?)

/live — Vivacité. Le processus est-il vivant et pas en deadlock ? (Peut-il répondre à une requête simple dans un délai imparti ?)

La distinction est importante pour Kubernetes, où les sondes de vivacité redémarrent les conteneurs bloqués et les sondes de disponibilité retirent les conteneurs du load balancer pendant le démarrage ou les pannes de dépendances.

typescript
// src/routes/health.ts
import { Router } from "express";
import { Pool } from "pg";
import Redis from "ioredis";
 
const router = Router();
 
interface HealthCheckResult {
  status: "ok" | "degraded" | "error";
  checks: Record<
    string,
    {
      status: "ok" | "error";
      latency?: number;
      message?: string;
    }
  >;
  uptime: number;
  timestamp: string;
  version: string;
}
 
async function checkDatabase(pool: Pool): Promise<{ ok: boolean; latency: number }> {
  const start = performance.now();
  try {
    await pool.query("SELECT 1");
    return { ok: true, latency: Math.round(performance.now() - start) };
  } catch {
    return { ok: false, latency: Math.round(performance.now() - start) };
  }
}
 
async function checkRedis(redis: Redis): Promise<{ ok: boolean; latency: number }> {
  const start = performance.now();
  try {
    await redis.ping();
    return { ok: true, latency: Math.round(performance.now() - start) };
  } catch {
    return { ok: false, latency: Math.round(performance.now() - start) };
  }
}
 
export function createHealthRoutes(pool: Pool, redis: Redis) {
  // Vivacité — juste vérifier si le processus peut répondre
  router.get("/live", (_req, res) => {
    res.status(200).json({ status: "ok" });
  });
 
  // Disponibilité — vérifier toutes les dépendances
  router.get("/ready", async (_req, res) => {
    const [db, cache] = await Promise.all([
      checkDatabase(pool),
      checkRedis(redis),
    ]);
 
    const allOk = db.ok && cache.ok;
 
    res.status(allOk ? 200 : 503).json({
      status: allOk ? "ok" : "not_ready",
      checks: {
        database: db,
        redis: cache,
      },
    });
  });
 
  // Santé complète — statut détaillé pour les tableaux de bord et le debugging
  router.get("/health", async (_req, res) => {
    const [db, cache] = await Promise.all([
      checkDatabase(pool),
      checkRedis(redis),
    ]);
 
    const anyError = !db.ok || !cache.ok;
    const allError = !db.ok && !cache.ok;
 
    const result: HealthCheckResult = {
      status: allError ? "error" : anyError ? "degraded" : "ok",
      checks: {
        database: {
          status: db.ok ? "ok" : "error",
          latency: db.latency,
          ...(!db.ok && { message: "Connection failed" }),
        },
        redis: {
          status: cache.ok ? "ok" : "error",
          latency: cache.latency,
          ...(!cache.ok && { message: "Connection failed" }),
        },
      },
      uptime: process.uptime(),
      timestamp: new Date().toISOString(),
      version: process.env.APP_VERSION || "unknown",
    };
 
    // Retourner 200 pour ok/degraded (le service peut encore gérer du trafic)
    // Retourner 503 pour error (le service devrait être retiré de la rotation)
    res.status(result.status === "error" ? 503 : 200).json(result);
  });
 
  return router;
}

Configuration des sondes Kubernetes#

yaml
# k8s/deployment.yml
spec:
  containers:
    - name: api
      livenessProbe:
        httpGet:
          path: /live
          port: 3000
        initialDelaySeconds: 10
        periodSeconds: 15
        timeoutSeconds: 5
        failureThreshold: 3    # Redémarrer après 3 échecs consécutifs (45s)
      readinessProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 5
        periodSeconds: 10
        timeoutSeconds: 5
        failureThreshold: 2    # Retirer du LB après 2 échecs (20s)
      startupProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 0
        periodSeconds: 5
        failureThreshold: 30   # Donner jusqu'à 150s pour le démarrage

Une erreur courante : rendre la sonde de vivacité trop agressive. Si ta sonde de vivacité vérifie la base de données, et que la base de données est temporairement en panne, Kubernetes va redémarrer ton conteneur. Mais redémarrer ne va pas réparer la base de données. Maintenant tu as un crash loop en plus d'une panne de base de données. Garde les sondes de vivacité simples — elles ne devraient détecter que les processus en deadlock ou bloqués.

Suivi d'erreurs avec Sentry#

Les logs attrapent les erreurs que tu avais prévues. Sentry attrape celles que tu n'avais pas prévues.

La différence est importante. Tu ajoutes des blocs try/catch autour du code que tu sais susceptible d'échouer. Mais les bugs qui comptent le plus sont dans le code que tu pensais sûr. Les rejets de promesse non gérés, les erreurs de type sur des réponses API inattendues, les accès null sur des chaînes optionnelles qui n'étaient pas si optionnelles.

Configuration Sentry pour Node.js#

typescript
// src/lib/sentry.ts
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
 
export function initSentry() {
  Sentry.init({
    dsn: process.env.SENTRY_DSN,
    environment: process.env.NODE_ENV || "development",
    release: process.env.APP_VERSION || "unknown",
 
    // Échantillonner 10% des transactions pour le monitoring de performance
    // (100% en développement)
    tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
 
    // Profiler 100% des transactions échantillonnées
    profilesSampleRate: 1.0,
 
    integrations: [
      nodeProfilingIntegration(),
      // Filtrer les erreurs bruyantes
      Sentry.rewriteFramesIntegration({
        root: process.cwd(),
      }),
    ],
 
    // Ne pas envoyer les erreurs depuis le développement
    enabled: process.env.NODE_ENV === "production",
 
    // Filtrer les non-problèmes connus
    ignoreErrors: [
      // Les déconnexions client ne sont pas des bugs
      "ECONNRESET",
      "ECONNABORTED",
      "EPIPE",
      // Les bots qui envoient n'importe quoi
      "SyntaxError: Unexpected token",
    ],
 
    // Supprimer les PII avant l'envoi
    beforeSend(event) {
      // Supprimer les adresses IP
      if (event.request) {
        delete event.request.headers?.["x-forwarded-for"];
        delete event.request.headers?.["x-real-ip"];
        delete event.request.cookies;
      }
 
      // Supprimer les paramètres de requête sensibles
      if (event.request?.query_string) {
        const params = new URLSearchParams(event.request.query_string);
        params.delete("token");
        params.delete("api_key");
        event.request.query_string = params.toString();
      }
 
      return event;
    },
  });
}

Gestionnaire d'erreurs Express avec Sentry#

typescript
// src/middleware/error-handler.ts
import * as Sentry from "@sentry/node";
import { getLogger } from "../lib/async-context";
import type { Request, Response, NextFunction } from "express";
 
// Le gestionnaire de requêtes Sentry doit venir en premier
export const sentryRequestHandler = Sentry.Handlers.requestHandler();
 
// Le gestionnaire de tracing Sentry
export const sentryTracingHandler = Sentry.Handlers.tracingHandler();
 
// Ton gestionnaire d'erreurs personnalisé vient en dernier
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  const log = getLogger();
 
  // Ajouter du contexte personnalisé à l'événement Sentry
  Sentry.withScope((scope) => {
    scope.setTag("route", req.route?.path || req.path);
    scope.setTag("method", req.method);
 
    if (req.user) {
      scope.setUser({
        id: req.user.id,
        // Ne pas envoyer l'email ou le nom d'utilisateur à Sentry
      });
    }
 
    // Ajouter des breadcrumbs pour le debugging
    scope.addBreadcrumb({
      category: "request",
      message: `${req.method} ${req.path}`,
      level: "info",
      data: {
        query: req.query,
        statusCode: res.statusCode,
      },
    });
 
    Sentry.captureException(err);
  });
 
  // Loguer l'erreur avec le contexte complet
  log.error(
    {
      err,
      statusCode: 500,
      route: req.route?.path || req.path,
      method: req.method,
    },
    "Unhandled error in request handler"
  );
 
  // Envoyer une réponse d'erreur générique
  // Ne jamais exposer les détails d'erreur au client en production
  res.status(500).json({
    error: "Internal Server Error",
    ...(process.env.NODE_ENV !== "production" && {
      message: err.message,
      stack: err.stack,
    }),
  });
}

Source maps#

Sans source maps, Sentry te montre des stack traces minifiées/transpilées. Inutilisable. Uploade les source maps pendant ton build :

bash
# Dans ton pipeline CI/CD
npx @sentry/cli sourcemaps upload \
  --org your-org \
  --project your-project \
  --release $APP_VERSION \
  ./dist

Ou configure-le dans ton bundler :

typescript
// vite.config.ts (ou équivalent)
import { sentryVitePlugin } from "@sentry/vite-plugin";
 
export default defineConfig({
  build: {
    sourcemap: true, // Requis pour Sentry
  },
  plugins: [
    sentryVitePlugin({
      org: process.env.SENTRY_ORG,
      project: process.env.SENTRY_PROJECT,
      authToken: process.env.SENTRY_AUTH_TOKEN,
    }),
  ],
});

Le coût des rejets de promesse non gérés#

Depuis Node.js 15, les rejets de promesse non gérés crashent le processus par défaut. C'est bien — ça te force à gérer les erreurs. Mais tu as besoin d'un filet de sécurité :

typescript
// src/server.ts — près du haut de ton point d'entrée
 
process.on("unhandledRejection", (reason, promise) => {
  logger.fatal({ reason, promise }, "Unhandled promise rejection — crashing");
  Sentry.captureException(reason);
 
  // Vider les événements Sentry avant de crasher
  Sentry.flush(2000).finally(() => {
    process.exit(1);
  });
});
 
process.on("uncaughtException", (error) => {
  logger.fatal({ err: error }, "Uncaught exception — crashing");
  Sentry.captureException(error);
 
  Sentry.flush(2000).finally(() => {
    process.exit(1);
  });
});

La partie importante : Sentry.flush() avant process.exit(). Sans ça, l'événement d'erreur pourrait ne pas arriver à Sentry avant que le processus ne meure.

Alerting : les alertes qui comptent vraiment#

Avoir 200 métriques Prometheus et zéro alerte, c'est du monitoring de vanité. Avoir 50 alertes qui se déclenchent tous les jours, c'est la fatigue d'alertes — tu commenceras à les ignorer, et alors tu rateras celle qui compte.

L'objectif est un petit nombre d'alertes à fort signal qui signifient « quelque chose ne va vraiment pas et un humain doit regarder ».

Configuration d'AlertManager pour Prometheus#

yaml
# alertmanager.yml
global:
  resolve_timeout: 5m
  slack_api_url: $SLACK_WEBHOOK_URL
 
route:
  receiver: "slack-warnings"
  group_by: ["alertname", "service"]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - match:
        severity: critical
      receiver: "pagerduty-critical"
      repeat_interval: 15m
    - match:
        severity: warning
      receiver: "slack-warnings"
 
receivers:
  - name: "pagerduty-critical"
    pagerduty_configs:
      - routing_key: $PAGERDUTY_ROUTING_KEY
        severity: critical
  - name: "slack-warnings"
    slack_configs:
      - channel: "#alerts"
        title: '{{ template "slack.title" . }}'
        text: '{{ template "slack.text" . }}'

Les alertes qui me réveillent vraiment#

yaml
# prometheus/rules/node-api.yml
groups:
  - name: node-api-critical
    rules:
      # Taux d'erreur élevé — quelque chose est cassé
      - alert: HighErrorRate
        expr: |
          (
            sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
            /
            sum(rate(nodeapp_http_requests_total[5m]))
          ) > 0.01
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Error rate above 1% for 5 minutes"
          description: "{{ $value | humanizePercentage }} of requests are returning 5xx"
 
      # Réponses lentes — les utilisateurs souffrent
      - alert: HighP99Latency
        expr: |
          histogram_quantile(0.99,
            sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
          ) > 1
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "p99 latency above 1 second for 5 minutes"
          description: "p99 latency is {{ $value | humanizeDuration }}"
 
      # Fuite mémoire — va OOM bientôt
      - alert: HighHeapUsage
        expr: |
          (
            nodeapp_nodejs_heap_size_used_bytes
            /
            nodeapp_nodejs_heap_size_total_bytes
          ) > 0.80
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "Heap usage above 80% for 10 minutes"
          description: "Heap usage is at {{ $value | humanizePercentage }}"
 
      # Processus en panne
      - alert: ServiceDown
        expr: up{job="node-api"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Node.js API is down"
 
  - name: node-api-warnings
    rules:
      # L'event loop ralentit
      - alert: HighEventLoopLag
        expr: |
          nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"} > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Event loop lag above 100ms"
          description: "p99 event loop lag is {{ $value | humanizeDuration }}"
 
      # Le trafic a chuté significativement — possible problème de routage
      - alert: TrafficDrop
        expr: |
          sum(rate(nodeapp_http_requests_total[5m]))
          < (sum(rate(nodeapp_http_requests_total[5m] offset 1h)) * 0.5)
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Traffic dropped by more than 50% compared to 1 hour ago"
 
      # Les requêtes de base de données ralentissent
      - alert: SlowDatabaseQueries
        expr: |
          histogram_quantile(0.99,
            sum(rate(nodeapp_db_query_duration_seconds_bucket[5m])) by (le, operation)
          ) > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "p99 database query time above 500ms"
          description: "Slow {{ $labels.operation }} queries: {{ $value | humanizeDuration }}"
 
      # L'API externe échoue
      - alert: ExternalAPIFailures
        expr: |
          (
            sum(rate(nodeapp_external_api_duration_seconds_count{status="error"}[5m])) by (service)
            /
            sum(rate(nodeapp_external_api_duration_seconds_count[5m])) by (service)
          ) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "External API {{ $labels.service }} failing >10%"

Note la clause for sur chaque alerte. Sans elle, un seul pic déclenche une alerte. Un for de 5 minutes signifie que la condition doit être vraie pendant 5 minutes continues. Ça élimine le bruit des pics momentanés.

Hygiène des alertes#

Chaque alerte doit passer ce test :

  1. Est-elle actionnable ? Si personne ne peut rien y faire, n'alerte pas. Logue-la, mets-la dans un tableau de bord, mais ne réveille personne.
  2. Nécessite-t-elle une intervention humaine ? Si ça se résout tout seul (comme une brève coupure réseau), la clause for devrait la filtrer.
  3. S'est-elle déclenchée dans les 30 derniers jours ? Si non, elle est peut-être mal configurée ou le seuil est mauvais. Révise-la.
  4. Quand elle se déclenche, les gens s'en soucient-ils ? Si l'équipe la rejette régulièrement, supprime-la ou corrige le seuil.

J'audite mes alertes trimestriellement. Chaque alerte reçoit l'un de trois résultats : garder, ajuster le seuil, ou supprimer.

Tout assembler : l'application Express#

Voici comment toutes les pièces s'emboîtent dans une vraie application :

typescript
// src/server.ts
import { initSentry } from "./lib/sentry";
 
// Initialiser Sentry en premier — avant les autres imports
initSentry();
 
import express from "express";
import * as Sentry from "@sentry/node";
import { Pool } from "pg";
import Redis from "ioredis";
import { logger } from "./lib/logger";
import { asyncContextMiddleware } from "./middleware/async-context-middleware";
import { metricsMiddleware } from "./middleware/metrics-middleware";
import { requestLogger } from "./middleware/request-logger";
import {
  sentryRequestHandler,
  sentryTracingHandler,
  errorHandler,
} from "./middleware/error-handler";
import { createHealthRoutes } from "./routes/health";
import metricsRouter from "./routes/metrics";
import apiRouter from "./routes/api";
 
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);
 
// --- L'ordre des middleware est important ---
 
// 1. Gestionnaire de requêtes Sentry (doit être en premier)
app.use(sentryRequestHandler);
app.use(sentryTracingHandler);
 
// 2. Contexte asynchrone (crée un contexte scopé à la requête)
app.use(asyncContextMiddleware);
 
// 3. Logging des requêtes
app.use(requestLogger);
 
// 4. Collecte de métriques
app.use(metricsMiddleware);
 
// 5. Parsing du body
app.use(express.json({ limit: "1mb" }));
 
// --- Routes ---
 
// Health checks (pas d'auth requise)
app.use(createHealthRoutes(pool, redis));
 
// Métriques (protégées par auth basique)
app.use(metricsRouter);
 
// Routes API
app.use("/api", apiRouter);
 
// --- Gestion des erreurs ---
 
// Gestionnaire d'erreurs Sentry (doit être avant le gestionnaire personnalisé)
app.use(Sentry.Handlers.errorHandler());
 
// Gestionnaire d'erreurs personnalisé (doit être en dernier)
app.use(errorHandler);
 
// --- Démarrage ---
 
const port = parseInt(process.env.PORT || "3000", 10);
 
app.listen(port, () => {
  logger.info(
    {
      port,
      nodeEnv: process.env.NODE_ENV,
      version: process.env.APP_VERSION,
    },
    "Server started"
  );
});
 
// Arrêt gracieux
async function shutdown(signal: string) {
  logger.info({ signal }, "Shutdown signal received");
 
  // Arrêter d'accepter de nouvelles connexions
  // Traiter les requêtes en cours (Express fait ça automatiquement)
 
  // Fermer le pool de base de données
  await pool.end().catch((err) => {
    logger.error({ err }, "Error closing database pool");
  });
 
  // Fermer la connexion Redis
  await redis.quit().catch((err) => {
    logger.error({ err }, "Error closing Redis connection");
  });
 
  // Vider Sentry
  await Sentry.close(2000);
 
  logger.info("Shutdown complete");
  process.exit(0);
}
 
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

La stack viable minimale#

Tout ce qui précède est la stack « complète ». Tu n'as pas besoin de tout dès le premier jour. Voici comment faire évoluer ton observabilité au fur et à mesure que ton projet grandit.

Étape 1 : projet perso / développeur solo#

Tu as besoin de trois choses :

  1. Des logs console structurés — Utilise Pino, produis du JSON sur stdout. Même si tu les lis juste avec pm2 logs, les logs JSON sont cherchables et parsables.

  2. Un endpoint /health — Prend 5 minutes à implémenter, te sauve quand tu debugues « est-ce que ça tourne au moins ? »

  3. Le tier gratuit de Sentry — Attrape les erreurs que tu n'as pas anticipées. Le tier gratuit te donne 5 000 événements/mois, c'est amplement suffisant pour un projet perso.

typescript
// C'est le setup minimal. Moins de 50 lignes. Pas d'excuses.
import pino from "pino";
import express from "express";
import * as Sentry from "@sentry/node";
 
const logger = pino({ level: "info" });
const app = express();
 
Sentry.init({ dsn: process.env.SENTRY_DSN });
app.use(Sentry.Handlers.requestHandler());
 
app.get("/health", (_req, res) => {
  res.json({ status: "ok", uptime: process.uptime() });
});
 
app.use("/api", apiRoutes);
 
app.use(Sentry.Handlers.errorHandler());
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  logger.error({ err }, "Unhandled error");
  res.status(500).json({ error: "Internal Server Error" });
});
 
app.listen(3000, () => logger.info("Server started on port 3000"));

Étape 2 : projet en croissance / petite équipe#

Ajouter :

  1. Métriques Prometheus + Grafana — Quand « ça a l'air lent » ne suffit plus et que tu as besoin de données. Commence par le taux de requêtes, le taux d'erreur et la latence p99.

  2. Agrégation de logs — Quand faire un ssh sur le serveur et greper dans des fichiers ne scale plus. Loki + Promtail si tu utilises déjà Grafana.

  3. Alertes basiques — Taux d'erreur > 1%, p99 > 1s, service en panne. Trois alertes. C'est tout.

Étape 3 : service en production / services multiples#

Ajouter :

  1. Tracing distribué avec OpenTelemetry — Quand « l'API est lente » devient « lequel des 5 services qu'elle appelle est lent ? » L'auto-instrumentation OTel te donne 80% de la valeur avec zéro changement de code.

  2. Tableau de bord as code — Versionne tes tableaux de bord Grafana. Tu te remercieras quand tu devras les recréer.

  3. Alerting structuré — AlertManager avec un routage, une escalade et des règles de silence appropriés.

  4. Métriques métier — Commandes/seconde, taux de conversion, profondeur de la file d'attente. Les métriques dont ton équipe produit se soucie.

Ce qu'il faut ignorer#

  • Les fournisseurs APM avec tarification par hôte — À grande échelle, le coût est absurde. L'open source (Prometheus + Grafana + Tempo + Loki) te donne 95% de la fonctionnalité.
  • Les niveaux de log en dessous d'INFO en production — Tu vas générer des téraoctets de logs DEBUG et payer pour le stockage. Utilise DEBUG uniquement quand tu investigues activement des problèmes, puis désactive-le.
  • Des métriques personnalisées pour tout — Commence par la méthode RED (Rate, Errors, Duration) pour chaque service. Ajoute des métriques personnalisées uniquement quand tu as une question spécifique à laquelle répondre.
  • L'échantillonnage de traces complexe — Commence par un taux d'échantillonnage simple (10% en production). L'échantillonnage adaptatif est une optimisation prématurée pour la plupart des équipes.

Réflexions finales#

L'observabilité n'est pas un produit que tu achètes ou un outil que tu installes. C'est une pratique. C'est la différence entre opérer ton service et espérer que ton service s'opère tout seul.

La stack que j'ai décrite ici — Pino pour les logs, Prometheus pour les métriques, OpenTelemetry pour les traces, Sentry pour les erreurs, Grafana pour la visualisation, AlertManager pour les alertes — n'est pas le setup le plus simple possible. Mais chaque pièce gagne sa place en répondant à une question que les autres pièces ne peuvent pas répondre.

Commence par des logs structurés et un endpoint de health. Ajoute des métriques quand tu as besoin de savoir « c'est grave à quel point ». Ajoute des traces quand tu as besoin de savoir « où passe le temps ». Chaque couche s'appuie sur la précédente, et aucune ne nécessite de réécrire ton application.

Le meilleur moment pour ajouter de l'observabilité, c'était avant ton dernier incident de production. Le deuxième meilleur moment, c'est maintenant.

Articles similaires