ITSkillsCenter
Business Digital

Instrumenter une application Node.js avec OpenTelemetry SDK pas-a-pas

14 دقائق للقراءة

📍 Article principal : Observabilité applicative en 2026 : OpenTelemetry, traces distribuées et stack LGTM — pour le contexte conceptuel et l’architecture d’ensemble.

Pourquoi instrumenter Node.js avec OpenTelemetry

Une application Node.js qui sert quelques requêtes par seconde paraît simple à diagnostiquer. La même application en production, sous charge, derrière un reverse proxy, qui appelle quatre microservices et trois APIs externes, devient une boîte noire dès la première anomalie. Les logs locaux ne suffisent plus à reconstituer ce qu’il s’est passé. C’est précisément la douleur que résout l’instrumentation OpenTelemetry : produire des traces, métriques et logs dans un format standard, exploitable par n’importe quel backend OTLP, sans verrouiller le code à un fournisseur unique.

Ce tutoriel construit, étape par étape, l’instrumentation d’un service Node.js minimal qui expose une route HTTP, fait une requête sortante et émet des spans manuels pour une opération métier. À la fin, vous aurez un service qui exporte ses traces et métriques en OTLP gRPC vers un Collector local, et vous aurez compris ce que chaque ligne du code apporte.

Prérequis

  • Node.js 20.6 ou plus récent (le SDK 2.x exige ^18.19.0 || >=20.6.0)
  • npm 10+ ou pnpm récent
  • Un OpenTelemetry Collector local en écoute sur 127.0.0.1:4317 (gRPC) — un tutoriel dédié couvre sa mise en place
  • Connaissance basique d’Express ou de l’API HTTP standard
  • Temps estimé : 35 à 45 minutes

Étape 1 — Initialiser le projet et installer les paquets OTel

L’écosystème OpenTelemetry JavaScript se compose de plusieurs paquets dont chacun a un rôle distinct. L’API (@opentelemetry/api) est l’interface stable consommée par le code applicatif. Le SDK Node (@opentelemetry/sdk-node) implémente la mécanique réelle. Les exporters traduisent les données vers le protocole OTLP. Les instrumentations automatiques patchent les bibliothèques populaires (HTTP, Express, pg, redis) pour produire des spans sans modification du code métier. On les installe ensemble.

mkdir otel-node-demo && cd otel-node-demo
npm init -y
npm install express
npm install \
  @opentelemetry/api \
  @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-grpc \
  @opentelemetry/exporter-metrics-otlp-grpc \
  @opentelemetry/sdk-metrics \
  @opentelemetry/resources \
  @opentelemetry/semantic-conventions

L’installation produit un package.json avec une dizaine de dépendances OpenTelemetry. À ce stade, rien n’est encore actif : ces paquets sont posés sur l’étagère, on va les brancher dans la suite. Si npm install échoue avec une erreur de version Node, c’est le signal d’un Node trop ancien — vérifier node -v et passer à 20 LTS minimum.

Étape 2 — Écrire le bootstrap d’instrumentation

OpenTelemetry doit être initialisé avant tout autre import qui pourrait être instrumenté. C’est une contrainte technique : les instrumentations automatiques fonctionnent en interceptant les require() au chargement, donc elles doivent être en place avant qu’Express ou le client HTTP ne soit chargé pour la première fois. La solution standard est un fichier instrumentation.js qu’on précharge avec --require.

// instrumentation.js
'use strict';

const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
const { OTLPMetricExporter } = require('@opentelemetry/exporter-metrics-otlp-grpc');
const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics');
const { resourceFromAttributes } = require('@opentelemetry/resources');
const {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} = require('@opentelemetry/semantic-conventions');

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: 'otel-node-demo',
    [ATTR_SERVICE_VERSION]: '1.0.0',
    'deployment.environment': process.env.NODE_ENV || 'development',
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://127.0.0.1:4317',
  }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://127.0.0.1:4317',
    }),
    exportIntervalMillis: 15000,
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

process.on('SIGTERM', () => {
  sdk.shutdown()
    .then(() => console.log('OTel terminated'))
    .catch((err) => console.error('OTel shutdown error', err))
    .finally(() => process.exit(0));
});

Ce fichier crée un NodeSDK qui combine traces et métriques en un seul objet. La resource identifie le service par son nom, sa version et son environnement — ces attributs apparaîtront sur tous les spans et toutes les métriques émis. Les exporters envoient les données vers un Collector OTLP gRPC sur le port standard 4317. Le hook SIGTERM garantit qu’au moment de l’arrêt, les données en buffer sont vidées avant que le processus ne meure, ce qui évite de perdre la dernière minute de données à chaque redémarrage.

Étape 3 — Écrire l’application Express minimale

L’application contient deux routes pour démontrer plusieurs aspects du tracing : une route triviale pour la requête entrante, et une route qui fait un appel HTTP sortant. Ce duo permet de voir comment les spans s’imbriquent et comment le contexte de trace se propage à travers les frontières du processus.

// server.js
'use strict';

const express = require('express');
const http = require('http');

const app = express();

app.get('/hello', (req, res) => {
  res.json({ message: 'hello', ts: Date.now() });
});

app.get('/joke', (req, res) => {
  http.get('http://api.icndb.com/jokes/random', (apiRes) => {
    let data = '';
    apiRes.on('data', (chunk) => (data += chunk));
    apiRes.on('end', () => {
      try {
        res.json(JSON.parse(data));
      } catch (e) {
        res.status(502).json({ error: 'upstream parse error' });
      }
    });
  }).on('error', (err) => {
    res.status(502).json({ error: err.message });
  });
});

const port = process.env.PORT || 3000;
app.listen(port, () => console.log('listening on', port));

Cette application n’a aucun appel à l’API OpenTelemetry. C’est volontaire : tout le tracing va se faire automatiquement grâce aux instrumentations chargées par le bootstrap. C’est l’intérêt majeur de l’auto-instrumentation : on commence par un service classique et on lui ajoute l’observabilité en une ligne d’arguments à node, sans jamais modifier le code métier.

Étape 4 — Lancer le service et confirmer l’export

On démarre le service en pré-chargeant le bootstrap. La variable OTEL_EXPORTER_OTLP_ENDPOINT peut surcharger l’URL si le Collector tourne ailleurs. Pour valider que l’export part bien, on active le mode debug du SDK qui écrit dans la console à chaque flush.

OTEL_LOG_LEVEL=info \
node --require ./instrumentation.js server.js

Au démarrage, le SDK écrit une ligne par instrumentation chargée. Une fois le serveur prêt, dans un autre terminal on appelle la route /hello, puis /joke. Si le Collector reçoit bien les spans, on voit dans ses logs des lignes du type Spans received: 2. Si rien n’arrive, vérifier que le Collector écoute sur 4317 et que le service ne pointe pas vers localhost:4318 (qui est le port HTTP, pas gRPC).

curl http://127.0.0.1:3000/hello
curl http://127.0.0.1:3000/joke

Chaque appel produit en cascade plusieurs spans : un span racine pour la requête entrante (auto-instrumentation HTTP), un span enfant pour le routage Express, et pour /joke un span supplémentaire pour la requête HTTP sortante. La hiérarchie est automatique et le trace_id est propagé via les en-têtes traceparent conformes à W3C Trace Context.

Étape 5 — Ajouter un span manuel pour une opération métier

L’auto-instrumentation couvre l’infrastructure (HTTP, base de données, broker), mais ne capture pas les opérations métier internes — un calcul, une validation, une transformation. Pour les rendre visibles dans les traces, on les enveloppe dans un span manuel. C’est l’instrumentation à valeur ajoutée : on ajoute du contexte là où la trace seule HTTP ne raconte pas l’histoire métier.

// scoring.js
'use strict';

const { trace, SpanStatusCode } = require('@opentelemetry/api');
const tracer = trace.getTracer('otel-node-demo');

async function scoreUser(userId) {
  return tracer.startActiveSpan('score.compute', async (span) => {
    try {
      span.setAttribute('user.id', userId);
      // simulation d'un calcul
      const result = Math.round(Math.random() * 1000);
      span.setAttribute('score.value', result);
      span.setStatus({ code: SpanStatusCode.OK });
      return result;
    } catch (err) {
      span.recordException(err);
      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
      throw err;
    } finally {
      span.end();
    }
  });
}

module.exports = { scoreUser };

Le pattern est canonique. On obtient un tracer nommé du module, on ouvre un span actif (ce qui le rend automatiquement parent de tout span créé en dessous), on attache des attributs métier (user.id, score.value), on encadre le bloc d’un try/catch pour positionner le statut à OK ou ERROR selon le résultat, et on appelle span.end() dans le finally. Ne jamais oublier end() : un span non clôturé ne sera jamais exporté.

On câble cette fonction dans une route :

// dans server.js
const { scoreUser } = require('./scoring');

app.get('/score/:id', async (req, res) => {
  const score = await scoreUser(req.params.id);
  res.json({ user: req.params.id, score });
});

Un appel à /score/42 produit désormais une trace à trois niveaux : span racine HTTP entrant, span Express, span score.compute. Dans Grafana branché sur Tempo, on voit l’arbre complet et on peut filtrer toutes les traces où user.id=42.

Étape 6 — Émettre une métrique métier

Les métriques répondent à une question différente : non plus « pourquoi cette requête a-t-elle été lente », mais « combien de scores avons-nous calculés par minute, et avec quelle distribution de valeurs ». L’API métriques d’OpenTelemetry expose plusieurs types d’instruments : Counter pour les compteurs monotones, UpDownCounter pour les jauges, Histogram pour les distributions de durées ou de tailles.

// scoring.js (extension)
const { metrics } = require('@opentelemetry/api');
const meter = metrics.getMeter('otel-node-demo');

const scoreCounter = meter.createCounter('app.score.computed', {
  description: 'Number of scores computed',
});
const scoreHistogram = meter.createHistogram('app.score.value', {
  description: 'Distribution of computed score values',
});

async function scoreUser(userId) {
  return tracer.startActiveSpan('score.compute', async (span) => {
    span.setAttribute('user.id', userId);
    const result = Math.round(Math.random() * 1000);
    scoreCounter.add(1);
    scoreHistogram.record(result);
    span.setAttribute('score.value', result);
    span.end();
    return result;
  });
}

Le compteur s’incrémente à chaque appel, l’histogramme accumule la distribution. Toutes les 15 secondes (intervalle configuré dans le bootstrap), le SDK exporte ces métriques vers le Collector. Côté backend, on retrouvera app_score_computed_total et app_score_value_bucket en PromQL.

Attention à la cardinalité des attributs sur les métriques : ne jamais passer user.id en attribut de métrique, car chaque utilisateur unique créera une nouvelle série temporelle. Les attributs de métrique doivent être à faible cardinalité (statut, méthode, type d’erreur), pas des identifiants.

Étape 7 — Connecter les logs à la trace

L’intérêt majeur d’avoir trace + logs ensemble est de pouvoir, depuis un log d’erreur, sauter directement à la trace correspondante dans Tempo. Cette corrélation passe par l’injection automatique du trace_id dans chaque log. La voie la plus simple est d’utiliser un logger qui supporte nativement OpenTelemetry — Pino avec pino-opentelemetry-transport est aujourd’hui le choix le plus mature côté Node.

npm install pino pino-opentelemetry-transport
// logger.js
'use strict';

const pino = require('pino');

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  transport: {
    targets: [
      { target: 'pino-opentelemetry-transport', level: 'info', options: {} },
      { target: 'pino/file', level: 'info', options: { destination: 1 } },
    ],
  },
});

module.exports = logger;

Le transport pino-opentelemetry-transport envoie les logs au LogRecordProcessor du SDK, qui les exporte en OTLP comme les traces et métriques. Chaque log émis dans le contexte d’un span actif porte automatiquement trace_id et span_id. Côté Grafana, un derived field sur le trace_id rend chaque ligne de log cliquable vers la trace correspondante.

Pour activer la pipeline logs côté SDK Node, il faut compléter le bootstrap avec un LogRecordProcessor et un exporter logs OTLP. Le détail de cette branche logs est creusé dans le tutoriel dédié Envoyer les logs vers Loki via OTLP ; on se contente ici de poser le câblage côté application.

Étape 8 — Vérifier le fonctionnement

Le test final consiste à provoquer du trafic et à vérifier que les trois signaux (traces, métriques, logs) atterrissent bien dans le Collector. Si le backend est branché à un Grafana avec Tempo, Loki et Mimir, on devrait voir l’arbre des spans pour /score/:id, le compteur app_score_computed qui monte, et les logs corrélés.

for i in $(seq 1 50); do
  curl -s "http://127.0.0.1:3000/score/$i" > /dev/null
done

Cinquante appels en boucle génèrent assez de signal pour observer dans le backend : 50 traces complètes, 50 incréments du compteur, et la distribution des valeurs dans l’histogramme. Si l’on n’a pas encore branché les backends, on peut configurer un Collector qui logue chaque batch reçu vers stdout pour valider la pipeline avant de complexifier davantage. Le tutoriel Configurer un OpenTelemetry Collector détaille cette mise en place.

Erreurs fréquentes

Importer un module avant le bootstrap

L’erreur la plus commune. Si une instruction require('express') apparaît avant le chargement de instrumentation.js, l’auto-instrumentation Express ne s’applique pas et aucun span n’est généré pour les routes. La règle est absolue : le bootstrap doit être chargé via --require ou en première ligne du point d’entrée, jamais après un autre require applicatif.

Mélanger gRPC et HTTP sur le port

Le Collector écoute par défaut sur 4317 pour OTLP gRPC et 4318 pour OTLP HTTP. Pointer un exporter gRPC vers 4318 produit des erreurs cryptiques de désérialisation côté Collector. Vérifier la cohérence du couple paquet exporter (otlp-grpc vs otlp-http) avec le port cible.

Oublier span.end()

Un span ouvert et jamais clôturé reste en mémoire jusqu’à ce que le processus s’arrête, et ne sera jamais exporté. Le pattern startActiveSpan avec finally { span.end() } protège contre cet oubli, surtout quand le bloc contient des erreurs ou des retours anticipés.

Mettre des identifiants en attributs de métriques

Sur une trace, mettre user.id comme attribut de span est utile et bon marché. Sur une métrique, le même attribut explose la cardinalité et peut faire tomber Mimir ou Prometheus. Les attributs de métriques doivent toujours être à valeurs énumérables.

Exporter en synchrone à chaque appel

Ne pas configurer SimpleSpanProcessor en production. Il exporte chaque span dès qu’il est clôturé, sans batching. Le coût réseau et CPU est prohibitif. Le SDK Node utilise par défaut BatchSpanProcessor, qui groupe et envoie périodiquement — c’est le bon choix tant qu’on ne fait pas de tests unitaires qui exigent un export immédiat.

Tutoriels associés

Ressources et références officielles

FAQ

Faut-il instrumenter manuellement chaque route ?

Non. Les instrumentations automatiques d’Express, Fastify, Koa, Hapi génèrent les spans des routes sans intervention. L’instrumentation manuelle est réservée aux opérations métier qui ne sont pas visibles depuis l’infrastructure.

Le SDK 2.0 casse-t-il du code existant ?

Oui sur deux points : Node 18.18 et antérieurs ne sont plus supportés, et certaines API internes ont été retirées au profit de versions arborescentes-secouables. Le code applicatif type (qui n’utilise que @opentelemetry/api) est compatible sans modification. Les bootstraps qui utilisaient des classes internes peuvent demander une mise à jour.

OTLP gRPC ou HTTP ?

gRPC est plus efficace en débit et en latence et reste le défaut côté Node. HTTP/protobuf est utile quand le réseau filtre HTTP/2 ou quand le Collector n’expose que le port HTTP. Aucune fonctionnalité ne dépend du choix : les deux portent les mêmes données.

Comment tester l’instrumentation localement sans backend ?

On utilise un Collector local en mode logging exporter, qui imprime sur stdout chaque span et chaque métrique reçus. C’est suffisant pour vérifier que la chaîne d’export fonctionne avant de brancher Tempo et Mimir. La configuration minimale tient en quinze lignes de YAML.

Le surcoût en performance est-il sensible ?

Pour un service Node typique, l’auto-instrumentation HTTP ajoute moins de 1 % de CPU et environ 200 µs par requête, dominés par la sérialisation des spans. À très haut débit (>10k req/s), des optimisations existent : sampling head-based pour réduire le volume, batching plus agressif, exporter UDP. Pour la majorité des services, le coût est négligeable comparé à la valeur diagnostique.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité