Cours NestJS · Module 12: Conteneurs et CI/CD
Distributed Tracing avec OpenTelemetry - les routes de courriers de l'Empire
Dans cette leçon5
Les métriques disent que le 95e percentile du temps de réponse est passé de 200 ms à deux secondes. Tu sais que c'est lent - mais pas où. Une requête traverse la porte d'authentification, le service des légions, la base et encore une API de paiement externe. Lequel de ces maillons prend ces deux secondes ?
Rome tenait pour cela un registre des stations. Un courrier portant une dépêche de Gaule à Rome se présentait à chaque mansio, et de ces rapports on pouvait ensuite lire où il avait perdu une journée. Le distributed tracing fait exactement cela avec une requête.
Trace et span
Deux notions à séparer sans tarder.
Une trace est la route entière d'une requête - de l'entrée à la sortie, à travers tous les services. Un span est une opération unique au sein d'une trace - une requête en base, un appel d'API externe, une méthode de service.
Un span n'est donc pas l'identifiant de toute la requête (c'est le trace ID), ni un format d'export de données, ni un outil de visualisation. C'est un tronçon de la route, avec son propre début et sa propre fin.
Les spans forment un arbre. Pour une requête créant une légion, il ressemble à ceci, de la racine vers le bas :
POST /legiones- le root span, toute la requête HTTP.auth.verify- la vérification du token.legion.create- la logique métier.mongodb.insert- l'écriture en base.
L'imbrication est une information en soi : puisque mongodb.insert se trouve à l'intérieur de legion.create, son temps s'ajoute à celui du parent. Quand le root span dure deux secondes et mongodb.insert une seconde et demie - tu sais déjà où chercher.
La configuration
Nous démarrons OpenTelemetry avant l'application, dans un fichier séparé chargé en premier :
1const sdk = new NodeSDK({
2 resource: new Resource({
3 [SemanticResourceAttributes.SERVICE_NAME]: 'legion-api',
4 }),
5 traceExporter: new OTLPTraceExporter({
6 url: 'http://jaeger:4318/v1/traces',
7 }),
8 instrumentations: [getNodeAutoInstrumentations()],
9});
10
11sdk.start();resource donne un nom au service - c'est par lui que tu reconnaîtras tes spans parmi ceux des autres services. traceExporter indique où envoyer les données ; ici vers Jaeger, l'outil qui en dessine une frise chronologique.
instrumentations est l'élément le plus intéressant. getNodeAutoInstrumentations() active le suivi automatique des bibliothèques courantes - HTTP, Express, MongoDB, Redis. Tu n'ajoutes pas une ligne dans tes services, et les spans des requêtes en base apparaissent d'eux-mêmes.
L'ordre compte : le SDK doit démarrer avant que tu n'importes Express ou le pilote de base. L'instrumentation automatique opère en remplaçant ces bibliothèques au chargement, et elle ne remplacera pas ce qui est déjà chargé.
Un span maison
L'auto-instrumentation couvre l'infrastructure mais ignore tout de ton domaine. Pour voir legion.create dans l'arbre, tu crées un span toi-même :
1@Injectable()
2export class LegionService {
3 private tracer = trace.getTracer('legion-service');
4
5 async create(dto: CreateLegionDto) {
6 return this.tracer.startActiveSpan('legion.create', async (span) => {
7 try {
8 span.setAttribute('legion.name', dto.name);
9 span.setAttribute('legion.rank', dto.rank);
10
11 const legion = await this.repo.save(dto);
12
13 span.setStatus({ code: SpanStatusCode.OK });
14 return legion;
15 } catch (error) {
16 span.recordException(error);
17 span.setStatus({ code: SpanStatusCode.ERROR });
18 throw error;
19 } finally {
20 span.end();
21 }
22 });
23 }
24}Tu crées le tracer une fois, comme champ de classe - l'ordre d'écriture est private tracer, =, trace.getTracer('legion-service').
Cinq choses se produisent à l'intérieur. startActiveSpan ouvre un span et le rend actif, si bien que tout ce qui naît à l'intérieur - y compris les spans de l'auto-instrumentation - s'y imbrique automatiquement. setAttribute ajoute des données recherchables ensuite dans Jaeger ; c'est là que tu inscris les identifiants par lesquels tu traqueras un cas précis. setStatus marque le résultat, recordException consigne une exception avec sa pile d'appels.
Le plus important est span.end() dans le bloc finally. Un span jamais terminé n'est jamais exporté - c'est la même symétrie que pour les connexions et les hooks de test : ce que tu as ouvert, tu dois le fermer, y compris quand une exception est partie.
La propagation du contexte
Reste la question de savoir comment des spans de services différents aboutissent dans un seul arbre. C'est le rôle de la context propagation - la transmission automatique du trace ID entre services par les en-têtes HTTP.
Quand le service A appelle le service B, l'instrumentation ajoute à la requête un en-tête traceparent portant l'identifiant de la trace et du span courant. Le service B le lit et crée ses spans comme enfants de celui-là. Personne ne copie quoi que ce soit à la main, ne synchronise de bases ni ne déplace de fichiers de configuration.
De là vient toute la valeur du tracing dans un système distribué : un seul arbre couvre tous les services, ces deux secondes peuvent donc être imputées à un maillon précis, même lorsqu'il se trouve dans le service d'autrui.
Récapitulatif
Les courriers se présentent à chaque station :
- les métriques disent que c'est lent, le tracing dit où,
- une trace est la route entière d'une requête ; un span est une opération unique au sein d'une trace - ni identifiant de requête, ni format d'export, ni outil de visualisation,
- les spans forment un arbre de la racine vers le bas :
POST /legiones→auth.verify→legion.create→mongodb.insert, - le temps d'un enfant s'ajoute à celui de son parent - c'est pourquoi l'arbre désigne aussitôt le maillon fautif,
NodeSDKse configure par trois choses :resourceavec le nom du service,traceExporteravec l'adresse du destinataire,instrumentations,getNodeAutoInstrumentations()suit les bibliothèques courantes sans changement de code - mais le SDK doit démarrer avant leur import,- le tracer :
private tracer=trace.getTracer('nom'), startActiveSpanimbrique tout ce qui naît en son sein ;setAttributeajoute des données recherchables,setStatusetrecordExceptiondécrivent le résultat,span.end()appartient aufinally- un span jamais terminé n'est jamais exporté,- la context propagation transmet automatiquement le trace ID entre services par les en-têtes HTTP - elle ne copie pas de journaux et ne synchronise pas de bases.
Dans la prochaine leçon, tu rassembleras tout ce module en un projet de déploiement. En attendant, retiens ceci : une métrique montre une courbe, une trace montre un itinéraire - et c'est seulement sur l'itinéraire qu'on voit à quelle station le courrier a perdu sa journée.
Code de cette leçon : src/distributed-tracing.ts
1// Distributed Tracing - Routes de courriers de l'Empire
2console.log("=== DISTRIBUTED TRACING ===\n");
3
4interface Span {
5 name: string;
6 traceId: string;
7 spanId: string;
8 parentSpanId: string | null;
9 duration: number;
10 status: 'OK' | 'ERROR';
11}
12
13// Simulation d'un arbre de spans
14const traceId = 'abc-123-def-456';
15const spans: Span[] = [
16 {
17 name: 'POST /legiones',
18 traceId,
19 spanId: 'span-001',
20 parentSpanId: null,
21 duration: 250,
22 status: 'OK',
23 },
24 {
25 name: 'auth.verify',
26 traceId,
27 spanId: 'span-002',
28 parentSpanId: 'span-001',
29 duration: 15,
30 status: 'OK',
31 },
32 {
33 name: 'legion.create',
34 traceId,
35 spanId: 'span-003',
36 parentSpanId: 'span-001',
37 duration: 180,
38 status: 'OK',
39 },
40 {
41 name: 'mongodb.insert',
42 traceId,
43 spanId: 'span-004',
44 parentSpanId: 'span-003',
45 duration: 45,
46 status: 'OK',
47 },
48 {
49 name: 'redis.set',
50 traceId,
51 spanId: 'span-005',
52 parentSpanId: 'span-003',
53 duration: 5,
54 status: 'OK',
55 },
56];
57
58console.log(`Trace ID : ${traceId}\n`);
59console.log("Arbre de spans :");
60spans.forEach(s => {
61 const indent = s.parentSpanId === null ? '' :
62 s.parentSpanId === 'span-001' ? ' ' : ' ';
63 console.log(`${indent}${s.name} [${s.duration}ms] ${s.status}`);
64});
65
66// Concepts clés
67console.log("\n=== CONCEPTS ===\n");
68const concepts = {
69 Trace: 'Parcours complet de la requête (client -> réponse)',
70 Span: 'Opération unique au sein d\'une trace',
71 'Trace ID': 'Identifiant unique de toute la requête',
72 'Context Propagation': 'Passage du traceId entre les services',
73};
74
75Object.entries(concepts).forEach(([k, v]) => {
76 console.log(` ${k} : ${v}`);
77});
78
79console.log("\nBackends : Jaeger, Zipkin, Grafana Tempo");
80Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Qu'est-ce qu'un Span dans le contexte du traçage distribué ?
2. Qu'est-ce que la Context Propagation dans le traçage distribué ?
Tâches pratiques dans le jeu
- Tri vertical
Ordonnez les spans du span racine jusqu'au plus profond :
- Éditeur de code
Configurez NodeSDK : resource avec le nom du service, OTLPTraceExporter avec l'URL vers Jaeger, auto-instrumentation
- Éditeur de code
Complétez startActiveSpan : définir les attributs (name, rank), enregistrer le statut OK/ERROR, appeler span.end() et gérer les erreurs
- Tri horizontal
Ordonnez les éléments de la création d'un tracer dans un service NestJS :