Aller au contenu
medium

Traces distribuées : comprendre spans, propagation et sampling

17 min de lecture

Une requête utilisateur traverse souvent 5, 10, voire 20 services avant de produire une réponse. Quand la latence explose ou qu’une erreur survient, les logs de chaque service montrent leur fragment de l’histoire — mais personne ne voit le trajet complet. Le tracing distribué résout ce problème : il reconstitue le parcours intégral d’une requête à travers tous les services, avec les durées de chaque étape. Vous identifiez en quelques secondes le temps est perdu et pourquoi.

  • Le problème : pourquoi les logs seuls ne suffisent pas pour diagnostiquer la latence dans un système distribué
  • Le span : l’unité de travail — nom, durée, parent, attributs, status
  • La trace : l’arbre de spans formant le parcours complet d’une requête
  • La propagation de contexte : comment le trace_id traverse les frontières de services (W3C Trace Context, header traceparent)
  • Le sampling : head-based vs tail-based — compromis volume/visibilité
  • Exemple visuel : anatomie d’une trace HTTP → API → Base de données

Le problème : reconstituer le parcours d’une requête

Section intitulée « Le problème : reconstituer le parcours d’une requête »

Imaginez un site e-commerce. Un client clique sur « Payer ». Cette action déclenche une chaîne :

  1. Le frontend envoie une requête HTTP
  2. L’API Gateway authentifie et route
  3. Le service commande crée la commande
  4. Le service paiement contacte le prestataire bancaire
  5. Le service stock décrémente l’inventaire

La réponse met 4,2 secondes. Le client abandonne. Où est le goulot d’étranglement ?

Chaque service a ses propres logs. Vous pouvez chercher dans chacun, mais :

  • Les horloges ne sont pas parfaitement synchronisées entre serveurs
  • Vous n’avez aucun identifiant commun pour relier les logs entre eux
  • Vous voyez des fragments, jamais le trajet complet

C’est exactement le problème que le tracing distribué résout. L’idée vient de Dapper, le système de tracing interne de Google décrit dans un article de recherche en 2010. Le concept a ensuite été standardisé par le projet OpenTelemetry et le W3C.

Un span (littéralement « empan », une étendue) représente une opération unique dans le traitement d’une requête. Pensez-y comme un chronomètre que vous démarrez au début d’une opération et arrêtez à la fin.

Chaque span contient au minimum :

ChampDescriptionExemple
trace_idIdentifiant commun à tous les spans d’une même requête (128 bits)4bf92f3577b34da6a3ce929d0e0e4736
span_idIdentifiant unique de ce span (64 bits, 16 hex)00f067aa0ba902b7
parent_span_idID du span parent (vide pour le root span, 64 bits, 16 hex)b9c7c989f97918e1
nameNom de l’opérationPOST /api/orders
start_timeHorodatage de début (haute précision, nanoseconde en OTel)2026-02-07T10:15:32.123456Z
durationDurée de l’opération245ms
statusRésultat : OK, ERROR, UNSETERROR
kindType de span (voir ci-dessous)SERVER
attributesPaires clé-valeur de métadonnéeshttp.method=POST, http.status_code=500

OpenTelemetry définit cinq types qui décrivent le rôle du span dans la communication :

SpanKindRôleExemple
CLIENTAppel sortant vers un autre serviceRequête HTTP vers le service paiement
SERVERTraitement d’un appel entrantRéception de la requête par le service paiement
INTERNALOpération locale, sans communication réseauCalcul du montant TTC
PRODUCEREnvoi d’un message asynchrone (file d’attente)Publication d’un événement dans Kafka
CONSUMERRéception d’un message asynchroneLecture d’un message depuis Kafka

La paire CLIENT / SERVER est la plus courante : un service crée un span CLIENT quand il appelle, le service appelé crée un span SERVER quand il reçoit. Les deux spans partagent le même trace_id.

Les attributs sont des paires clé-valeur libres qui enrichissent le span. OpenTelemetry définit des conventions sémantiques pour harmoniser les noms :

# Attributs HTTP standardisés
http.request.method = "POST"
http.response.status_code = 201
url.full = "https://api.example.com/orders"
# Attributs base de données
db.system = "postgresql"
db.statement = "SELECT * FROM orders WHERE id = $1"
db.operation.name = "SELECT"
# Attributs métier (libres)
order.id = "ORD-2026-4521"
order.total_amount = 149.90

Bonnes pratiques pour les attributs :

  • Utiliser les conventions sémantiques OpenTelemetry quand elles existent
  • Ajouter des attributs métier pertinents pour le diagnostic (ID commande, ID utilisateur anonymisé, montant)
  • Ne jamais inclure de données sensibles (mots de passe, tokens, PII non anonymisées)
  • Limiter le nombre d’attributs par span (au-delà de 128, la plupart des backends tronquent)

Une trace est un ensemble de spans reliés par leurs parent_span_id, formant un arbre. Le span racine (root span) n’a pas de parent — il représente le point d’entrée de la requête dans le système.

Reprenons le scénario du paiement. Voici la trace complète, visualisée comme un diagramme de Gantt (ce que vous verrez dans Jaeger, Tempo ou Zipkin) :

trace_id: 4bf92f3577b34da6a3ce929d0e0e4736
Service Span Durée Statut
─────────────────────────────────────────────────────────
API Gateway POST /checkout 4200ms ERROR
├─ Svc Commande createOrder 320ms OK
├─ Svc Paiement processPayment 3600ms ERROR
│ ├─ Paiement validateCard 45ms OK
│ └─ Paiement callBankAPI 3500ms ERROR ← goulot
└─ Svc Stock decrementStock 80ms OK

Lecture de la trace :

  • Le root span est POST /checkout (4200 ms au total)
  • Le service commande (createOrder) prend 320 ms — normal
  • Le service paiement prend 3600 ms, dont 3500 ms dans l’appel à l’API bancaire — c’est le goulot
  • Le service stock est rapide (80 ms)
  • Le statut ERROR se propage : callBankAPIprocessPaymentPOST /checkout

Sans trace, vous auriez vu une erreur 500 dans les logs du Gateway, et vous auriez cherché dans 5 services différents. Avec la trace, vous identifiez callBankAPI en quelques secondes.

Le tracing distribué ne fonctionne que si chaque service transmet le trace_id au suivant. C’est la propagation de contexte (context propagation).

Avant 2020, chaque outil de tracing utilisait ses propres headers : X-B3-TraceId (Zipkin), uber-trace-id (Jaeger), X-Cloud-Trace-Context (Google). Résultat : si deux services utilisaient des outils différents, la trace était coupée.

Le W3C a standardisé deux headers HTTP avec la spécification Trace Context 1.0 (Recommendation du 23 novembre 2021) :

traceparent : le header principal. Format :

traceparent: {version}-{trace-id}-{parent-id}-{trace-flags}
Exemple :
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ │ │ └─ flags (2 hex, 01 = sampled)
│ │ └─ parent-id (16 hex = SpanId)
│ └─ trace-id (32 hex = TraceId)
└─ version (2 hex, toujours 00 actuellement)

Attention : trace-id fait 32 hex (128 bits) et parent-id fait 16 hex (64 bits) — ce n’est pas la même taille.

tracestate : header compagnon pour les informations spécifiques à un vendor (Datadog, New Relic, etc.). Il transporte des paires clé-valeur propres à chaque outil, sans casser l’interopérabilité.

À côté de traceparent, on rencontre aussi baggage (W3C Baggage) pour transporter du contexte applicatif entre services — identifiant de tenant, segment utilisateur, feature flag. À limiter strictement : attention aux PII et au volume, chaque paire clé-valeur est propagée à tous les services en aval.

  1. Le premier service crée le contexte

    Le service qui reçoit la requête initiale (sans traceparent entrant) génère un trace_id et un span_id, puis les attache à ses appels sortants via le header traceparent.

  2. Chaque service propage le contexte

    Quand un service reçoit un traceparent, il extrait le trace_id et le parent-id. Il crée un nouveau span avec le même trace_id, un nouveau span_id, et le parent-id reçu comme parent_span_id. Il propage le traceparent mis à jour à ses propres appels sortants.

  3. Le backend recollecte et reconstitue

    Chaque service envoie ses spans au collecteur (OTLP, Jaeger, Zipkin). Le backend de tracing rassemble tous les spans d’un même trace_id et reconstitue l’arbre.

Avant W3C Trace ContextAvec W3C Trace Context
Chaque outil a son header propriétaireUn seul header traceparent universel
Trace coupée si deux services utilisent des outils différentsInteropérabilité garantie
Migration d’outil = reconfiguration de tous les servicesMigration transparente
Corrélation manuelle par timestampsCorrélation automatique par trace_id

En production, un système peut traiter des millions de requêtes par heure. Traquer chaque requête est coûteux en CPU, en bande passante et en stockage. Le sampling (échantillonnage) sélectionne un sous-ensemble de traces à conserver.

Le premier service décide au moment où la requête arrive si elle sera tracée ou non. La décision est encodée dans le flag trace-flags du traceparent (01 = sampled, 00 = not sampled). Tous les services en aval respectent cette décision.

Avantages :

  • Simple : un seul point de décision
  • Coût prévisible : ratio fixe (par exemple 10 % des requêtes)
  • Pas de buffering : chaque service sait immédiatement s’il doit exporter ou non

Inconvénient majeur : la décision est prise avant de savoir si la requête sera intéressante. Une erreur rare sur 0,1 % des requêtes a 90 % de chances de ne pas être échantillonnée avec un ratio de 10 %.

Un collecteur central (comme l’OpenTelemetry Collector avec le processeur tail_sampling) reçoit tous les spans, reconstitue les traces complètes, et décide ensuite lesquelles conserver selon des règles :

  • Garder toutes les traces en erreur
  • Garder les traces dont la latence dépasse un seuil
  • Garder un échantillon aléatoire des traces normales
  • Garder les traces contenant certains attributs (utilisateur VIP, endpoint critique)

Avantages :

  • Ne rate jamais les erreurs — toute trace anormale est capturée
  • Règles intelligentes — décision basée sur le contenu réel de la trace

Inconvénients :

  • Coût réseau : tous les spans doivent être envoyés au collecteur (même ceux qui seront rejetés)
  • Mémoire : le collecteur doit bufferiser les spans le temps de reconstituer la trace complète (typiquement 30 secondes à quelques minutes)
  • CPU côté services : le tail-based réduit le stockage final et améliore la couverture des erreurs, mais les services instrumentent et exportent quand même tous les spans vers le collecteur — le coût d’instrumentation côté application reste non nul
  • Complexité : nécessite un collecteur central dimensionné pour le volume total
CritèreHead-basedTail-based
DécisionAu premier serviceAu collecteur central
Coût réseauFaible (seuls les spans échantillonnés transitent)Élevé (tous les spans transitent)
Couverture des erreursIncomplète (échantillonnage aléatoire)Complète (règles sur le résultat)
Complexité opérationnelleFaibleÉlevée (collecteur à dimensionner)
MémoireNégligeableSignificative (bufferisation)

En pratique, les deux approches se combinent : head-based sampling pour éliminer un pourcentage de traces triviales au plus tôt (économie de bande passante), puis tail-based sampling au collecteur pour appliquer des règles intelligentes sur les traces restantes.

Un span pèse typiquement entre 200 et 500 octets selon le nombre d’attributs. Quelques ordres de grandeur :

Volume de requêtesSpans/min (3 spans/requête)Stockage brut/jour
100 rps18 000~5 Go
1 000 rps180 000~50 Go
10 000 rps1 800 000~500 Go

Ces chiffres représentent le volume brut des spans, hors compression, index et métadonnées du backend. Le coût réel de stockage dépend de votre solution (Tempo, Jaeger, Elasticsearch…), mais l’ordre de grandeur reste valable pour dimensionner le sampling. Sans échantillonnage, un service à 10 000 rps génère plusieurs centaines de Go/jour de données de tracing — souvent plus que les logs et métriques combinés.

PiègePourquoi c’est un problèmeSolution
Trace coupée à un serviceUn service ne propage pas le traceparent (proxy, load balancer, middleware)Vérifier que chaque composant du chemin propage les headers W3C
Confondre trace applicative et trace distribuéeLes « traces » de debug (niveau TRACE dans les logs) n’ont rien à voir avec le tracing distribuéUtiliser le terme span ou trace distribuée pour éviter l’ambiguïté
Trop d’attributs par spanAu-delà de ~128 attributs, les backends tronquent et le coût exploseSe limiter aux attributs nécessaires au diagnostic + conventions sémantiques
Pas de sampling100 % de tracing à fort volume = coût réseau et stockage prohibitifMettre en place du head-based puis du tail-based progressivement
Instrumenter uniquement les appels HTTPLes appels à la base de données, au cache, aux files d’attente restent invisiblesInstrumenter aussi les clients DB, Redis, Kafka avec les SDK OTel
Ignorer les appels asynchronesLa trace s’arrête au PRODUCER — le CONSUMER crée une trace séparéePropager le contexte dans les headers de message, ou utiliser des Span Links quand la relation n’est pas parent/enfant (batching, fan-out, async) mais qu’on veut relier les contextes
  1. Une trace est un arbre de spans reliés par un trace_id commun — elle reconstitue le parcours complet d’une requête

  2. Un span est l’unité de travail : nom de l’opération, durée, parent_span_id, attributs et status

  3. La propagation de contexte repose sur le header HTTP traceparent (W3C Trace Context) au format {version}-{trace-id}-{parent-id}-{trace-flags}

  4. Le sampling contrôle le volume : head-based (décision à l’entrée, simple, ratio fixe) ou tail-based (décision au collecteur, intelligent mais coûteux en ressources)

  5. Les deux types de sampling se combinent : head-based pour réduire la bande passante, tail-based pour capturer toutes les anomalies

  6. Le trace_id est le pont entre les trois signaux : il relie les spans aux logs (champ trace_id dans le log structuré) et aux métriques (exemplars)

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.