Skip to content

Observabilité Node — logs, traces, metrics

TL;DR — L'observabilité moderne en Node 2026 repose sur trois piliers indissociables : logs structurés JSON (pino, niveau de prod), traces distribuées (OpenTelemetry SDK Node auto-instrumenté), et metrics (Prometheus client ou OTel metrics). Le ciment qui les relie : un corrélation ID propagé via AsyncLocalStorage à travers tout le call stack, qu'on retrouve dans chaque log, chaque span et chaque metric label. Sans ça, l'observabilité reste une collection de signaux disjoints inutilisables en debug réel. La méthode RED (Rate, Errors, Duration) et les golden signals (latence, trafic, erreurs, saturation) donnent le cadre.

🧠 Mental model — ASCII + analogie

Trois piliers, un fil rouge :

   ┌──────────────────────────────────────────────────────────────┐
   │  TRACE : "que s'est-il passé pour cette requête ?"          │
   │  → spans imbriqués, latence par étape, errors                │
   │  → outil : OpenTelemetry → Jaeger/Tempo/Datadog              │
   ├──────────────────────────────────────────────────────────────┤
   │  LOG : "qu'a fait le code à un instant T ?"                 │
   │  → texte structuré, niveau, contexte                         │
   │  → outil : pino → ELK/Loki/Datadog                          │
   ├──────────────────────────────────────────────────────────────┤
   │  METRIC : "à quel rythme et avec quelle santé globale ?"   │
   │  → compteurs, histogrammes, gauges agrégés                  │
   │  → outil : prom-client / OTel → Prometheus + Grafana        │
   └──────────────────────────────────────────────────────────────┘
              │            │            │
              └────────────┼────────────┘

              ┌────────────────────────┐
              │  CORRELATION ID        │
              │  trace_id + span_id    │
              │  AsyncLocalStorage     │
              └────────────────────────┘

Analogie : une production sans observabilité, c'est piloter un avion la nuit sans instruments. Les logs sont la radio (raconte ce que tu fais), les traces sont le GPS (montre ton itinéraire), les metrics sont les jauges (carburant, altitude, vitesse). Tu as besoin des trois — chacun raconte une histoire que les autres ne peuvent pas.

🛠️ Code minimal — Logs avec pino

pino est le logger Node de référence : structuré JSON par défaut, ultra-rapide, conçu pour être agrégé.

ts
// src/logger.ts
import pino from 'pino'

export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  timestamp: pino.stdTimeFunctions.isoTime,
  base: {
    service: 'orders-api',
    env: process.env.NODE_ENV,
    version: process.env.APP_VERSION,
  },
  redact: {
    paths: ['req.headers.authorization', '*.password', '*.creditCard'],
    censor: '[REDACTED]',
  },
  formatters: {
    level: (label) => ({ level: label }),
  },
})
ts
// src/handler.ts
import { logger } from './logger.ts'

export async function handleOrder(req, res) {
  const log = logger.child({ requestId: req.id, userId: req.user?.id })
  log.info({ method: req.method, path: req.url }, 'order received')
  try {
    const result = await processOrder(req.body)
    log.info({ orderId: result.id, ms: Date.now() - req.t0 }, 'order processed')
    res.json(result)
  } catch (err) {
    log.error({ err }, 'order failed')
    res.status(500).json({ error: 'internal' })
  }
}

Règles d'or :

  • Structuré, pas string. log.info({ userId, orderId }, 'created') plutôt que log.info('user u-1 created order o-2'). Cherchable, agrégeable.
  • Niveaux disciplinés : trace (dev only), debug (désactivé en prod par défaut), info (événements métier), warn (situations dégradées récupérables), error (échec d'opération), fatal (process down).
  • Sampling pour les logs verbeux : 1% en prod sur les info de routine, 100% sur warn/error.
  • Redaction des secrets : tokens, passwords, PII. Toujours configurer redact.

🛠️ Code minimal — OpenTelemetry Node SDK

L'OTel SDK Node fournit un setup auto-instrumenté qui couvre Express, Fastify, http, fetch, pg, redis, mongo, kafka, etc. sans rien coder.

bash
pnpm add @opentelemetry/sdk-node \
         @opentelemetry/auto-instrumentations-node \
         @opentelemetry/exporter-trace-otlp-http \
         @opentelemetry/exporter-metrics-otlp-http \
         @opentelemetry/resources \
         @opentelemetry/semantic-conventions
ts
// src/telemetry.ts
import { NodeSDK } from '@opentelemetry/sdk-node'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'
import { resourceFromAttributes } from '@opentelemetry/resources'
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: 'orders-api',
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION ?? 'dev',
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/traces',
  }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/metrics',
    }),
    exportIntervalMillis: 30_000,
  }),
  instrumentations: [getNodeAutoInstrumentations({
    '@opentelemetry/instrumentation-fs': { enabled: false },  // bruit énorme
    '@opentelemetry/instrumentation-http': {
      ignoreIncomingRequestHook: (req) => req.url === '/health',
    },
  })],
})

sdk.start()

process.on('SIGTERM', () => {
  sdk.shutdown().finally(() => process.exit(0))
})
ts
// src/index.ts — IMPORTANT : importer telemetry AVANT tout le reste
import './telemetry.ts'
import { startServer } from './server.ts'
startServer()

Avec ça, toute requête HTTP entrante, toute requête sortante (fetch, pg, redis) génère un span. Les exporters envoient à un collector OTLP (Tempo, Jaeger, Datadog, Honeycomb).

🛠️ Spans manuels et contexte

Pour les opérations métier que tu veux observer :

ts
import { trace, SpanStatusCode } from '@opentelemetry/api'

const tracer = trace.getTracer('orders-api')

export async function processOrder(input: OrderInput) {
  return tracer.startActiveSpan('processOrder', async (span) => {
    span.setAttribute('order.itemCount', input.items.length)
    span.setAttribute('order.totalCents', input.totalCents)

    try {
      const validated = await validate(input)
      const persisted = await persist(validated)
      await publish('order.created', persisted)
      span.setStatus({ code: SpanStatusCode.OK })
      return persisted
    } catch (err) {
      span.recordException(err as Error)
      span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message })
      throw err
    } finally {
      span.end()
    }
  })
}

🛠️ Metrics — Prometheus client

Pour les metrics, deux options. prom-client reste le plus pragmatique pour Prometheus pur, OTel Metrics si tu veux tout unifié.

ts
// src/metrics.ts
import { Registry, Counter, Histogram, collectDefaultMetrics } from 'prom-client'

export const registry = new Registry()
collectDefaultMetrics({ register: registry })  // CPU, RAM, GC, event loop lag

export const httpRequests = new Counter({
  name: 'http_requests_total',
  help: 'Total HTTP requests',
  labelNames: ['method', 'route', 'status'] as const,
  registers: [registry],
})

export const httpDuration = new Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP request duration in seconds',
  labelNames: ['method', 'route', 'status'] as const,
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
  registers: [registry],
})
ts
// src/middleware/metrics.ts
import { httpRequests, httpDuration } from '../metrics.ts'

export function metricsMiddleware(req, res, next) {
  const end = httpDuration.startTimer({ method: req.method, route: req.route?.path ?? 'unknown' })
  res.on('finish', () => {
    const labels = { method: req.method, route: req.route?.path ?? 'unknown', status: String(res.statusCode) }
    httpRequests.inc(labels)
    end({ status: String(res.statusCode) })
  })
  next()
}
ts
// src/server.ts — endpoint /metrics
app.get('/metrics', async (req, res) => {
  res.setHeader('Content-Type', registry.contentType)
  res.end(await registry.metrics())
})

🛠️ AsyncLocalStorage pour correlation ID

C'est le secret du succès. Sans ça, tu ne peux pas retrouver tous les logs liés à une requête donnée.

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

export type RequestContext = { requestId: string; userId?: string; traceId?: string }
export const als = new AsyncLocalStorage<RequestContext>()

// Middleware d'entrée
export function contextMiddleware(req, res, next) {
  const requestId = req.headers['x-request-id'] ?? randomUUID()
  const ctx: RequestContext = { requestId, userId: req.user?.id }
  als.run(ctx, () => next())
}

// Dans le logger
import pino from 'pino'
export const logger = pino({
  mixin() {
    const ctx = als.getStore()
    return ctx ? { requestId: ctx.requestId, userId: ctx.userId } : {}
  },
})

Chaque logger.info(...) enrichit automatiquement avec requestId, et tu peux maintenant filtrer requestId=abc-123 pour voir tout ce qui s'est passé pour cette requête, même à travers des fonctions async profondes.

🎯 Patterns courants

1. RED method (Rate, Errors, Duration)

Pour chaque service, exposer trois métriques fondamentales :

  • Rate : requêtes par seconde
  • Errors : taux d'erreur (4xx, 5xx)
  • Duration : latence (p50, p95, p99)
promql
# Prometheus / Grafana queries
rate(http_requests_total[1m])                                              # Rate
rate(http_requests_total{status=~"5.."}[1m]) / rate(http_requests_total[1m])  # Error rate
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))   # p95

2. Golden Signals (SRE Book)

Quatre signaux universels :

  • Latency : combien de temps une requête prend (avec distinction success vs failure)
  • Traffic : combien de requêtes
  • Errors : taux d'erreur
  • Saturation : à quel point le système est plein (CPU, mémoire, queues, event loop lag)

Pour le saturation Node-spécifique :

ts
import { monitorEventLoopDelay } from 'node:perf_hooks'

const histogram = monitorEventLoopDelay({ resolution: 20 })
histogram.enable()

setInterval(() => {
  // Exporter en metric
  eventLoopLagP99.set(histogram.percentile(99) / 1e6)  // ms
  histogram.reset()
}, 10_000)

3. Sampling pour les traces

100% de traces en prod = explosion de coûts. Solution : sampling avec head sampling (décidé au début) ou tail sampling (décidé après).

ts
import { TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base'

const sdk = new NodeSDK({
  sampler: new TraceIdRatioBasedSampler(0.1),  // 10%
  // ...
})

Pour ne sampler que les requêtes en erreur (tail sampling), tu as besoin d'un OTel Collector configuré avec tail_sampling processor en aval.

4. Logs sampling

Idem pour les logs :

ts
const logger = pino({
  level: 'info',
  base: { service: 'api' },
  hooks: {
    logMethod(args, method, level) {
      if (level === 30 /* info */ && Math.random() > 0.1) return  // 10% des info
      return method.apply(this, args)
    },
  },
})

Mais jamais sampler les warn/error. Sinon tu rates les bugs.

5. Correlation entre logs et traces

Quand un log et un span concernent la même requête, le trace_id doit être dans le log :

ts
import { context, trace } from '@opentelemetry/api'

export const logger = pino({
  mixin() {
    const span = trace.getActiveSpan()
    if (!span) return {}
    const { traceId, spanId } = span.spanContext()
    return { trace_id: traceId, span_id: spanId }
  },
})

Dans ton outil d'observabilité (Datadog, Grafana, Honeycomb), tu cliques sur un log et tu vois directement le trace associé.

6. Health endpoints distincts de l'observabilité

ts
app.get('/health/liveness',  (_, res) => res.json({ status: 'ok' }))      // process up ?
app.get('/health/readiness', async (_, res) => {                          // ready to serve ?
  try {
    await Promise.all([db.query('SELECT 1'), redis.ping()])
    res.json({ status: 'ready' })
  } catch (err) {
    res.status(503).json({ status: 'not ready', err: String(err) })
  }
})

K8s utilise ces endpoints pour décider du routage. Ne les confonds pas avec /metrics.

🔄 Versions — Node 18 / 20 / 22 / 24

VersionObservabilité
18AsyncLocalStorage stable, perf_hooks complet, OTel SDK fonctionne
20perf_hooks.monitorEventLoopDelay officiellement stable
22Built-in node:diagnostics_channel mature (utilisé par OTel auto-instrumentations modernes), process.report exhaustif
24Performance V8 améliorée → moins d'overhead du SDK OTel (~5-10%)

⚠️ Pitfalls

  • OTel SDK importé après l'app : les auto-instrumentations doivent être chargées avant tout import de http/express/etc. Sinon elles ne peuvent pas patcher les modules.
  • console.log partout : non structuré, non corrélé, non sampling. Bannis-le en prod. Configure un ESLint pour no-console.
  • Logs unstructured (template strings) : logger.info('user ${u} did ${a}'). Difficile à requêter, casse les filtres. Utilise un objet.
  • Cardinalité explosive des metrics labels : userId ou requestId comme label = explosion de séries (un million d'users = un million de séries). Garde les labels low-cardinality (route, method, status).
  • Sampling appliqué aux erreurs : si tu samples les traces à 10% et qu'un bug ne déclenche que 0.1% du trafic, tu n'as 0.01% de chance de l'attraper. Configure un error sampler qui force 100% sur les erreurs.
  • Pas de requestId propagé entre services : la trace s'arrête à la frontière du service. Solution : propage traceparent (W3C Trace Context) avec OTel propagators, automatique sur les libs auto-instrumentées.
  • Logs non bufferisés bloquent le process : pino.destination() synchrone à stderr = blocage. Utilise le mode async par défaut, ou pino.transport() pour exporter vers un autre process.
  • Coûts d'observabilité non maîtrisés : Datadog/Honeycomb facturent au volume. Sans sampling et sans discipline, la facture explose. Mesure les volumes envoyés (les outils l'exposent) et alerte sur les déviations.
  • Date.now() au lieu de performance.now() pour mesurer la latence : Date.now() a une précision de ~1ms, performance.now() est microseconde. Pour les histogrammes p99 sous 10ms, ça compte.
  • OTel Collector absent : envoyer directement à Datadog/Honeycomb depuis Node fonctionne, mais sans batching, sans retry, sans transformation. Un collector intermédiaire (OTel Collector) est la bonne pratique en prod.

🧪 Testing — vérifier l'observabilité

ts
// On peut tester que les bonnes metrics/logs/spans sont émises
import { test } from 'node:test'
import { trace } from '@opentelemetry/api'
import { InMemorySpanExporter } from '@opentelemetry/sdk-trace-base'

test('processOrder émet un span avec attributs', async () => {
  const exporter = new InMemorySpanExporter()
  // setup OTel avec cet exporter en mémoire
  await processOrder({ items: [], totalCents: 1000 })
  const spans = exporter.getFinishedSpans()
  assert.ok(spans.find(s => s.name === 'processOrder'))
  assert.equal(spans[0].attributes['order.totalCents'], 1000)
})

🎬 Cas d'usage concrets

Scénario 1 — SaaS RH "PaySimple" OpenTelemetry multi-service

PaySimple a 12 microservices Node + 4 workers (RabbitMQ + BullMQ) + 1 frontend Next.js. Avant 2024, chaque service avait son propre système de logs (Winston, Pino, console), pas de tracing distribué, et les incidents prenaient des heures à débugger parce qu'on devait corréler manuellement les logs entre services via grep + timestamps.

Migration à OpenTelemetry en 2024 : tous les services exportent traces + metrics + logs vers un collector OTel central, qui dispatche vers Tempo (traces), Prometheus (metrics) et Loki (logs). Le traceId et spanId sont injectés dans tous les logs Pino via un hook. Un même traceId permet de suivre une requête depuis le front Next.js → API core → worker BullMQ → notification email → SMS. Bénéfice mesuré : MTTR (Mean Time To Resolve) divisé par 4 (de 45 min à 11 min en moyenne), parce qu'un dev clique sur un span lent dans Grafana Tempo et voit immédiatement quel sous-service / quelle requête SQL est le coupable. L'instrumentation automatique (@opentelemetry/auto-instrumentations-node) couvre Fastify, Postgres, Redis, RabbitMQ, HTTP client out — zéro code à écrire pour 90 % des spans.

Scénario 2 — Cabinet juridique "LexFidens" pino logs structurés + compliance RGPD

LexFidens manipule des données extrêmement sensibles (actes notariés, contrats clients, données bancaires). Les logs sont soumis à un audit annuel CNIL : prouver qui a accédé à quel dossier, quand, depuis quelle IP, et que les données personnelles ne fuitent pas en clair dans les logs. Solution : Pino avec serializers custom et redact configuré strictement.

ts
const logger = pino({
  level: 'info',
  redact: {
    paths: ['*.password', '*.token', '*.iban', '*.cardNumber', '*.dateOfBirth', 'req.headers.authorization', 'req.headers.cookie'],
    censor: '[REDACTED]',
  },
  serializers: { req: pinoReq, res: pinoRes, err: pino.stdSerializers.err },
})

Tous les logs sont structurés JSON, expédiés vers Loki, et indexés par dossierId, userId, clientId. Une requête Grafana "qui a accédé au dossier X entre le 1er et le 15 mai" est answered en 3 secondes. La rétention est de 5 ans (obligation notariale). Aucune donnée RGPD sensible n'apparaît jamais dans les logs (vérifié par un script Python qui scanne 10 % des logs prod chaque nuit à la recherche de patterns IBAN, numéros de carte, dates de naissance). L'audit CNIL 2025 est passé sans réserve.

Scénario 3 — E-commerce "ModeCircuit" metrics SLA et alerting

ModeCircuit a un SLA contractuel avec ses revendeurs : 99.9 % de disponibilité sur l'API checkout, latence p99 < 500 ms. Pour le mesurer et l'alerter, l'équipe utilise OTel Metrics + Prometheus + Grafana + Alertmanager.

Les métriques clés exposées : http.server.duration_ms (histogram), http.server.request.count (counter, partitionné par route + status), db.query.duration_ms (histogram), payment.stripe.error.count (counter). Les alertes Prometheus :

yaml
- alert: CheckoutLatencyP99High
  expr: histogram_quantile(0.99, sum(rate(http_server_duration_ms_bucket{route="/checkout"}[5m])) by (le)) > 500
  for: 5m
  annotations:
    summary: "Checkout p99 > 500ms"
- alert: StripeErrorSpike
  expr: rate(payment_stripe_error_count[1m]) > 5
  for: 2m
  annotations:
    summary: "Stripe errors > 5/min, possible outage"

L'équipe a aussi un dashboard "Business KPI" qui croise les metrics techniques avec des KPI business (orders/min, AOV, conversion rate). Quand un déploiement dégrade la latence checkout, le taux de conversion baisse mécaniquement, et l'équipe le voit en temps réel — décision rapide de rollback. Le SLA 99.9 % est tenu 14 mois consécutifs en 2025-2026.

🛠️ Exemple end-to-end

Cas d'usage : "service PaySimple api-payslips instrumenté de bout en bout — OTel traces + metrics + Pino logs avec traceId, AsyncLocalStorage pour le contexte, et un span manuel autour du calcul de fiche de paie".

ts
// src/observability/index.ts
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 { resourceFromAttributes } from '@opentelemetry/resources'
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'
// Note 2026 : `SemanticResourceAttributes` (l'ancien enum) a été SUPPRIMÉ.
// On utilise les constantes `ATTR_*` stables, et les incubating
// (`ATTR_DEPLOYMENT_ENVIRONMENT_NAME`) depuis `.../incubating`.
import { ATTR_DEPLOYMENT_ENVIRONMENT_NAME } from '@opentelemetry/semantic-conventions/incubating'

export const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: 'paysimple-api-payslips',
    [ATTR_SERVICE_VERSION]: process.env.GIT_SHA ?? 'dev',
    [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: process.env.ENV ?? 'dev',
  }),
  traceExporter: new OTLPTraceExporter({ url: process.env.OTEL_TRACE_ENDPOINT }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({ url: process.env.OTEL_METRICS_ENDPOINT }),
    exportIntervalMillis: 10_000,
  }),
  instrumentations: [getNodeAutoInstrumentations({
    '@opentelemetry/instrumentation-fs': { enabled: false },
  })],
})
sdk.start()

process.on('SIGTERM', async () => {
  await sdk.shutdown()
  process.exit(0)
})

// src/observability/logger.ts
import pino from 'pino'
import { trace, context } from '@opentelemetry/api'

export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  base: { service: 'paysimple-api-payslips', env: process.env.ENV ?? 'dev' },
  redact: { paths: ['*.password', '*.token', 'req.headers.authorization'], censor: '[REDACTED]' },
  mixin() {
    const span = trace.getSpan(context.active())
    if (!span) return {}
    const ctx = span.spanContext()
    return { traceId: ctx.traceId, spanId: ctx.spanId }
  },
})

// src/observability/metrics.ts
import { metrics } from '@opentelemetry/api'
const meter = metrics.getMeter('paysimple-api-payslips')

export const payslipDuration = meter.createHistogram('payslip.calculation.duration_ms', {
  description: 'Duration of payslip calculation',
  unit: 'ms',
})
export const payslipErrors = meter.createCounter('payslip.calculation.error.count')
export const payslipsCreated = meter.createCounter('payslip.created.count')

// src/payslip/calculatePayslipInstrumented.ts
import { trace, SpanStatusCode } from '@opentelemetry/api'
import { calculatePayslip } from './calculate'
import { logger } from '../observability/logger'
import { payslipDuration, payslipErrors, payslipsCreated } from '../observability/metrics'

const tracer = trace.getTracer('paysimple-api-payslips')

export async function calculatePayslipWithObservability(input: {
  employeeId: string
  month: `${number}-${number}`
  components: any[]
}) {
  return tracer.startActiveSpan('payslip.calculate', async (span) => {
    span.setAttributes({
      'employee.id': input.employeeId,
      'payslip.month': input.month,
      'payslip.componentCount': input.components.length,
    })
    const startedAt = process.hrtime.bigint()
    try {
      const result = calculatePayslip(input as any)
      span.setAttributes({
        'payslip.gross': result.totals.gross,
        'payslip.netAfterTax': result.totals.netAfterTax,
      })
      payslipsCreated.add(1, { month: input.month })
      logger.info({ employeeId: input.employeeId, month: input.month, gross: result.totals.gross }, 'payslip computed')
      return result
    } catch (err) {
      const error = err as Error
      span.recordException(error)
      span.setStatus({ code: SpanStatusCode.ERROR, message: error.message })
      payslipErrors.add(1, { reason: error.name })
      logger.error({ err: error, employeeId: input.employeeId }, 'payslip computation failed')
      throw error
    } finally {
      const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6
      payslipDuration.record(elapsedMs, { month: input.month })
      span.end()
    }
  })
}

Cet exemple combine huit pratiques observability seniors : (1) initialisation OTel SDK avec resource attributes complets (service.name, service.version, deployment.environment), (2) auto-instrumentation Node activée pour Fastify, Postgres, Redis (zero-code pour 90 % des spans), (3) Pino logger avec mixin qui injecte automatiquement traceId et spanId dans chaque log (corrélation logs ↔ traces dans Grafana), (4) redact strict des secrets dans Pino, (5) métriques métier custom (payslip.created.count, payslip.calculation.duration_ms) avec labels pertinents (mois, raison d'erreur), (6) span manuel payslip.calculate avec attributes business (employee.id, gross, netAfterTax) pour le drilldown Tempo, (7) recordException + setStatus(ERROR) pour que les traces sortent rouges quand ça casse, (8) process.hrtime.bigint() pour la mesure de latence monotone (nanoseconde, non affecté par les sauts d'horloge système). Cette stack a permis à PaySimple de diviser le MTTR par 4 en 12 mois.

🧠 Comment un staff engineer raisonne sur l'observabilité

L'observabilité n'est pas un projet "on installe pino + OTel et c'est fini". C'est un budget qu'on alloue et qu'on défend. Trois axes de raisonnement que les juniors zappent :

1. Coût ≠ valeur — la courbe est non-linéaire. Doubler le volume de traces ne double pas la valeur de debug. Au-delà d'un certain taux de sampling, on paie pour de la redondance. Le staff engineer pense en termes de valeur marginale du signal : la 1000ᵉ trace réussie identique n'apprend rien ; la 1ʳᵉ trace en erreur vaut de l'or. D'où la stratégie gagnante : tail sampling biaisé (100 % erreurs + 100 % latence anormale + 1-5 % du reste). Le head sampling TraceIdRatioBasedSampler est un pis-aller quand on n'a pas de collector.

StratégieDécisionCapture les erreurs rares ?CoûtComplexité
No samplingOui💸💸💸Faible
Head (ratio)Au début, sur trace_idNon (probabiliste)💸Faible
TailAprès, sur le trace completOui (règles)💸💸 (buffering collector)Élevée
Head + force-on-errorAu début, mais 100 % si erreur localePartiel (rate l'erreur en aval)💸Moyenne

2. Cardinalité = la dette cachée des metrics. Une metric Prometheus, c'est nom + ensemble de labels → une série temporelle stockée. http_requests_total{route, method, status} = routes × méthodes × statuts séries, gérable. Ajoute userId et tu passes à des millions de séries : ton Prometheus OOM, les queries timeout, la facture Datadog explose. Règle staff : les labels sont un produit cartésien — chaque label doit avoir une cardinalité bornée et connue à l'avance. Le high-cardinality (userId, requestId, orderId) appartient aux traces et aux logs, jamais aux metrics. C'est la distinction fondamentale entre les trois piliers : metrics = agrégat low-cardinality, traces/logs = événements high-cardinality.

3. L'observabilité doit survivre à sa propre panne. Si le collector OTel tombe, le service ne doit pas tomber avec lui. Les exporters OTLP doivent avoir un timeout court et droper en silence (jamais bloquer le hot path). Pino en mode async ne doit jamais bloquer l'event loop sur un disque lent. Question de design : « mon instrumentation peut-elle causer l'incident qu'elle est censée m'aider à débugger ? » — si oui, c'est un anti-pattern. C'est le piège classique : un mixin pino qui throw, un exporter synchrone, un span jamais end() qui fuit en mémoire.

Le « débit d'observabilité » comme SLI interne. Les équipes matures monitorent leur observabilité elle-même : volume de spans/s droppés par le collector, ratio de logs perdus, lag du metric reader, taille du buffer d'export. Une obs qui ment (signaux perdus silencieusement) est pire qu'une obs absente, parce qu'elle crée une fausse confiance.


🔁 Quand utiliser / éviter

OutilQuandÉviter quand
pinoLogs prod, perf critiqueLogs simples en dev (console suffit)
winstonCodebase historiqueGreenfield (pino fait mieux)
OTel SDKTout service Node en prodScript CLI one-shot
prom-clientStack Prometheus existanteStack 100% OTel
OTel metricsStack OTel unifiéeStack legacy Prometheus uniquement
AsyncLocalStorageTout service avec requêtes corréléesLib pure sans concept de requête

🛠️ Dashboards Grafana — exemples utiles

Quatre dashboards qui couvrent 90% des cas en Node :

1. RED dashboard

promql
# Rate
sum by (route) (rate(http_requests_total[1m]))

# Error rate
sum by (route) (rate(http_requests_total{status=~"5.."}[1m]))
/
sum by (route) (rate(http_requests_total[1m]))

# Duration p95
histogram_quantile(0.95,
  sum by (route, le) (rate(http_request_duration_seconds_bucket[5m]))
)

2. Node process health

promql
# Memory usage
process_resident_memory_bytes / 1024 / 1024     # MB

# Event loop lag
nodejs_eventloop_lag_seconds                    # cible < 0.05s

# GC time
rate(nodejs_gc_duration_seconds_sum[1m])

# Open file descriptors
process_open_fds                                # alerter si proche de la limite

# Heap usage
nodejs_heap_size_used_bytes / nodejs_heap_size_total_bytes

3. Saturation

promql
# CPU
rate(process_cpu_seconds_total[1m]) * 100

# Active handles (connexions, timers)
nodejs_active_handles_total

# Active requests (in-flight)
nodejs_active_requests_total

4. Errors par type

promql
sum by (type) (rate(application_errors_total[5m]))

Avec une métrique custom :

ts
import { Counter } from 'prom-client'

const errorCounter = new Counter({
  name: 'application_errors_total',
  help: 'errors by type',
  labelNames: ['type'] as const,
  registers: [registry],
})

try {
  await operation()
} catch (err) {
  errorCounter.inc({ type: err.constructor.name })
  throw err
}

🛠️ Alerting — règles de base

Les alertes doivent pointer vers des symptômes (latence, erreurs), pas des causes (CPU à 80%). Le SRE Book appelle ça "alert on symptoms, not causes".

yaml
# alerts.yml — Prometheus alerting rules
groups:
- name: orders-api
  rules:
  - alert: HighErrorRate
    expr: |
      sum(rate(http_requests_total{status=~"5..",service="orders-api"}[5m]))
      / sum(rate(http_requests_total{service="orders-api"}[5m]))
      > 0.05
    for: 10m
    labels: { severity: page }
    annotations:
      summary: "Error rate > 5% pour orders-api"
      runbook: "https://wiki/runbooks/orders-api-errors"

  - alert: HighLatencyP95
    expr: |
      histogram_quantile(0.95,
        sum by (le) (rate(http_request_duration_seconds_bucket{service="orders-api"}[5m]))
      ) > 1.0
    for: 10m
    labels: { severity: page }
    annotations: { summary: "p95 latency > 1s" }

  - alert: EventLoopLagHigh
    expr: nodejs_eventloop_lag_seconds{service="orders-api"} > 0.1
    for: 5m
    labels: { severity: ticket }
    annotations:
      summary: "Event loop lag > 100ms — risque de timeouts"

Trois niveaux :

  • Page : réveille un humain (5xx > 5%, latence p95 dérive, service down).
  • Ticket : alerte business hours (CPU élevé, event loop lent).
  • Info : juste dans le dashboard, pas d'alerte (saturation modérée).

🛠️ Tracing OTel — propagation cross-service

Quand le service A appelle le service B via fetch, la trace doit continuer.

ts
// Service A — appel sortant
import { propagation, context } from '@opentelemetry/api'

const headers: Record<string, string> = {}
propagation.inject(context.active(), headers)

await fetch('https://service-b/api', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', ...headers },  // traceparent injecté
  body: JSON.stringify(data),
})
ts
// Service B — réception
import { propagation, context, trace } from '@opentelemetry/api'

app.use((req, res, next) => {
  const ctx = propagation.extract(context.active(), req.headers)
  context.with(ctx, () => {
    const span = trace.getTracer('service-b').startSpan('handle-request')
    res.on('finish', () => span.end())
    next()
  })
})

En pratique, les auto-instrumentations OTel font ça automatiquement. Mais si tu fais un fetch custom ou utilises un protocole non standard, tu dois injecter manuellement.

🔗 Liens

🏋️ Exercices

Progression : instrumenter → rendre production-grade → casser puis réparer. Fais-les dans l'ordre, chacun s'appuie sur le précédent. Cible : Node 22/24, ESM, TypeScript, node:test.

Exercice 1 — Le fil rouge complet (requestId + traceId)

Objectif : un serveur Fastify où chaque log de chaque couche async (handler → service → repo) porte automatiquement le même requestId ET le traceId OTel, sans passer le contexte en argument.

Monte un middleware qui crée un RequestContext dans un AsyncLocalStorage, un logger pino dont le mixin lit l'ALS et le span actif, et un endpoint qui appelle trois fonctions imbriquées loggant chacune. Vérifie en node:test que les trois logs partagent le même requestId.

Indice / Solution

Le piège : si tu fais als.run(ctx, () => next()) mais que Fastify perd le contexte sur un await, c'est que tu utilises un hook qui casse la chaîne async. Utilise le hook onRequest et als.enterWith(ctx) (ou enveloppe tout le cycle de vie). Le mixin fusionne deux sources : als.getStore() pour le requestId métier et trace.getActiveSpan()?.spanContext() pour trace_id/span_id. Test : capture les logs via un pino writable stream custom, parse le JSON, assert sur l'égalité des requestId.

Exercice 2 — Middleware metrics RED zéro-cardinalité-folle

Objectif : un middleware metrics qui expose http_requests_total et http_request_duration_seconds SANS jamais exploser la cardinalité, même quand les routes contiennent des IDs dynamiques (/orders/8f3a-...).

Le label route doit être le template (/orders/:id), jamais l'URL concrète. Écris une fonction qui dérive le template depuis le routeur (Fastify expose req.routeOptions.url), et un test qui envoie 1000 requêtes sur 1000 IDs différents puis vérifie qu'il n'existe qu'une seule série dans le registry.

Indice / Solution

registry.getMetricsAsJSON() te donne les séries. Après 1000 requêtes sur /orders/:id, le http_requests_total doit avoir exactement une combinaison {method, route="/orders/:id", status}. Si tu vois 1000 séries, c'est que tu labellises avec req.url au lieu du template. Bonus : ajoute un garde-fou runtime qui throw si un label dépasse N valeurs distinctes (détection de cardinalité explosive en dev/CI).

Exercice 3 — Propagation cross-service W3C Trace Context

Objectif : deux services Node (A → B via fetch) où une seule trace relie les deux, vérifiable par l'égalité du trace_id côté A et côté B.

Sans auto-instrumentation : injecte manuellement avec propagation.inject côté A, extrais avec propagation.extract + context.with côté B. Configure le W3CTraceContextPropagator. Prouve avec un InMemorySpanExporter des deux côtés que les spans partagent le même traceId et que le span B a bien le span A comme parent.

Indice / Solution

Le header clé est traceparent (format 00-{traceId}-{spanId}-{flags}). Côté B, l'erreur classique : démarrer le span AVANT context.with(extractedCtx, ...) → il devient une racine orpheline au lieu d'un enfant. L'ordre correct : extractcontext.with(ctx, () => tracer.startSpan(...)). Assert : spanA.spanContext().traceId === spanB.spanContext().traceId et spanB.parentSpanContext?.spanId === spanA.spanContext().spanId.

Exercice 4 — Production-grade : sampling avec force-on-error

Objectif : un sampler custom qui échantillonne 5 % du trafic normal MAIS force 100 % des traces contenant une erreur ou dépassant un seuil de latence.

Le head sampling pur rate les erreurs rares (cf. la table de raisonnement plus haut). Implémente soit un Sampler composite côté SDK (force-on-error local), soit — la vraie solution prod — décris la config tail_sampling du OTel Collector qui le fait globalement. Compare les deux approches dans un court paragraphe : pourquoi le tail sampling au collector est strictement supérieur pour les erreurs en aval.

Indice / Solution

Côté SDK, un Sampler ne connaît que le span racine au moment de la décision — il ne sait pas encore si une erreur surviendra plus tard. D'où la limite : le force-on-error local ne capture que les erreurs connues à l'ouverture du span. Le tail sampling collector bufferise le trace complet puis décide → il voit l'erreur en aval. Config collector : tail_sampling processor avec policies [{type: status_code, status_codes: [ERROR]}, {type: latency, threshold_ms: 500}, {type: probabilistic, sampling_percentage: 5}]. Le coût : le collector doit garder les traces en RAM le temps de la decision_wait.

Exercice 5 — Break-then-fix : l'instrumentation qui tue le service

Objectif : reproduire trois pannes causées par l'observabilité elle-même, puis les corriger.

Casse délibérément : (a) un mixin pino qui throw quand als.getStore() est undefined → crash sur les logs hors requête ; (b) une fuite mémoire de spans jamais end() dans une branche d'erreur → heap qui grimpe ; (c) un exporter OTLP synchrone/bloquant pointant vers un collector éteint → event loop lag qui explose. Écris un test ou un script qui observe chaque symptôme (heap snapshot, monitorEventLoopDelay), puis applique le fix et re-mesure.

Indice / Solution

(a) mixin doit être total : try { ... } catch { return {} } et toujours gérer le store absent. (b) Le span.end() doit être dans un finally, jamais dans le happy path uniquement — c'est exactement pourquoi l'exemple end-to-end met end() dans finally. Mesure la fuite avec process.memoryUsage().heapUsed avant/après 100k spans non fermés. (c) L'export doit être batché + timeout court + non bloquant ; mesure l'event loop lag avec monitorEventLoopDelay({resolution: 20}) collector up vs down. Leçon : l'obs ne doit jamais être dans le chemin critique synchrone.

Exercice 6 (bonus, hard) — Cardinalité runtime guard + budget d'obs

Objectif : un wrapper autour de prom-client qui refuse (ou alerte) tout label dépassant un budget de cardinalité, et expose une meta-metric observability_series_total pour s'auto-monitorer.

Intercepte .inc() / .observe(), maintiens un Set des combinaisons de labels par metric, et throw/warn au-delà du seuil. Expose le nombre de séries actives comme metric. Bonus ultime : fais-en un test qui simule une fuite de cardinalité (userId en label) et vérifie que le guard la détecte en < 100 séries.

Indice / Solution

Décore le registry : à chaque écriture, calcule une clé stable des labels (JSON.stringify des valeurs triées), insère dans un Map<metricName, Set<labelKey>>. Au-delà du budget (ex. 500 séries/metric), lève une erreur explicite citant les labels coupables. La meta-metric se met à jour dans un collect() callback. C'est exactement le garde-fou que les plateformes obs internes (mais pas Prometheus out-of-the-box) fournissent.

🎤 En entretien

Q : Pourquoi a-t-on besoin des trois piliers — un seul (les logs) ne suffit-il pas ? Parce qu'ils répondent à des questions différentes à des coûts différents : les metrics donnent la santé agrégée bon marché et alertable (low-cardinality, rétention longue) ; les traces donnent le « où » et le « combien de temps » sur le chemin d'une requête précise (high-cardinality, drilldown) ; les logs donnent le « quoi exactement » avec le détail métier. Tout reconstruire depuis les logs coûte une cardinalité et un stockage prohibitifs et ne donne ni agrégat alertable ni topologie de spans.

Q : Quelle est la différence fondamentale entre ce qu'on met en label de metric et en attribut de span ? La cardinalité. Un label de metric multiplie le nombre de séries temporelles stockées (produit cartésien) → il doit être borné et bas (route, method, status, env). Un attribut de span vit sur un événement unique → il peut être high-cardinality (userId, orderId, traceId). Mettre un userId en label Prometheus est l'erreur prod classique qui fait OOM le TSDB et exploser la facture.

Q : Tu samples les traces à 10 % et un bug touche 0,1 % du trafic. Que se passe-t-il, et comment tu corriges ? Avec du head sampling probabiliste, tu n'as que ~0,01 % de chance de capturer une occurrence → tu débugges à l'aveugle. Le fix : tail sampling au niveau du OTel Collector, qui bufferise le trace complet et garde 100 % des traces en erreur ou à latence anormale, plus un faible pourcentage du reste. Le head sampling ne peut pas décider sur une erreur qui survient après l'ouverture du span.

Q : Pourquoi import './telemetry.ts' doit-il être la toute première ligne du process ? Les auto-instrumentations OTel fonctionnent par monkey-patching des modules (http, pg, express…) au moment du require/import. Si l'app importe http ou Express avant que le SDK ne soit initialisé, les modules sont déjà chargés non patchés → aucun span n'est généré. En ESM, on garantit l'ordre via --import ./telemetry.mjs au lancement (node --import), car le hoisting des import rend l'ordre dans le fichier non fiable.

🗓️ Récap final

L'observabilité en Node 2026, c'est trois piliers reliés par un fil rouge : pino pour les logs structurés, OTel SDK pour les traces auto-instrumentées, prom-client (ou OTel metrics) pour les métriques. Le fil rouge : AsyncLocalStorage qui propage le correlation ID et le trace ID partout, jusque dans chaque log. Au-dessus : RED ou golden signals comme cadre mental, sampling discipliné (100% erreurs, 1-10% routine), cardinalité maîtrisée. Et la règle absolue : importe telemetry.ts avant tout le reste, sinon rien ne s'instrumente. Une fois ce trio en place, le debug en prod passe de "chercher une aiguille dans une botte de foin" à "filtrer par requestId puis suivre le trace".

Bibliothèque tech perso — Achref