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é.
// 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 }),
},
})// 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 quelog.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
infode 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.
pnpm add @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http \
@opentelemetry/resources \
@opentelemetry/semantic-conventions// 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))
})// 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 :
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é.
// 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],
})// 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()
}// 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.
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)
# 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])) # p952. 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 :
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).
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 :
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 :
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é
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
| Version | Observabilité |
|---|---|
| 18 | AsyncLocalStorage stable, perf_hooks complet, OTel SDK fonctionne |
| 20 | perf_hooks.monitorEventLoopDelay officiellement stable |
| 22 | Built-in node:diagnostics_channel mature (utilisé par OTel auto-instrumentations modernes), process.report exhaustif |
| 24 | Performance 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
importde http/express/etc. Sinon elles ne peuvent pas patcher les modules. console.logpartout : non structuré, non corrélé, non sampling. Bannis-le en prod. Configure un ESLint pourno-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 :
userIdourequestIdcomme 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
requestIdpropagé entre services : la trace s'arrête à la frontière du service. Solution : propagetraceparent(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, oupino.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 deperformance.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é
// 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.
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 :
- 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".
// 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égie | Décision | Capture les erreurs rares ? | Coût | Complexité |
|---|---|---|---|---|
| No sampling | — | Oui | 💸💸💸 | Faible |
| Head (ratio) | Au début, sur trace_id | Non (probabiliste) | 💸 | Faible |
| Tail | Après, sur le trace complet | Oui (règles) | 💸💸 (buffering collector) | Élevée |
| Head + force-on-error | Au début, mais 100 % si erreur locale | Partiel (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
| Outil | Quand | Éviter quand |
|---|---|---|
| pino | Logs prod, perf critique | Logs simples en dev (console suffit) |
| winston | Codebase historique | Greenfield (pino fait mieux) |
| OTel SDK | Tout service Node en prod | Script CLI one-shot |
| prom-client | Stack Prometheus existante | Stack 100% OTel |
| OTel metrics | Stack OTel unifiée | Stack legacy Prometheus uniquement |
| AsyncLocalStorage | Tout service avec requêtes corrélées | Lib pure sans concept de requête |
🛠️ Dashboards Grafana — exemples utiles
Quatre dashboards qui couvrent 90% des cas en Node :
1. RED dashboard
# 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
# 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_bytes3. Saturation
# CPU
rate(process_cpu_seconds_total[1m]) * 100
# Active handles (connexions, timers)
nodejs_active_handles_total
# Active requests (in-flight)
nodejs_active_requests_total4. Errors par type
sum by (type) (rate(application_errors_total[5m]))Avec une métrique custom :
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".
# 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.
// 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),
})// 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
- pino : https://getpino.io/
- OpenTelemetry Node : https://opentelemetry.io/docs/languages/js/
- prom-client : https://github.com/siimon/prom-client
- SRE Book — Golden Signals : https://sre.google/sre-book/monitoring-distributed-systems/
- RED method : https://www.weave.works/blog/the-red-method-key-metrics-for-microservices-architecture/
- W3C Trace Context : https://www.w3.org/TR/trace-context/
- OTel Collector : https://opentelemetry.io/docs/collector/
🏋️ 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 : extract → context.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".