Observabilité — 03 — Traces distribuées : spans, context propagation, sampling

Comment les traces distribuées relient les services entre eux. Spans, context propagation W3C TraceContext, stratégies de sampling. Exemples TypeScript et Python.

03 — Traces distribuées : spans, context propagation, sampling

Ce que tu vas apprendre

  • Ce qu'est une trace et un span — la structure de base
  • Comment le contexte se propage entre services (W3C TraceContext)
  • Les stratégies de sampling : ne pas tout tracer
  • Les backends : Jaeger, Tempo, Zipkin
  • Exemples TypeScript et Python manuels (avant OpenTelemetry)

Prérequis


Trace et span : les concepts de base

Une trace représente le parcours complet d'une requête à travers le système — de la réception par l'API jusqu'à la réponse finale. Elle est identifiée par un traceId unique.

Un span est une unité de travail dans cette trace. Chaque appel de service, chaque requête BDD, chaque opération significative est un span. Un span a :

  • Un spanId unique
  • Un traceId (partagé avec tous les spans de la même trace)
  • Un parentSpanId (sauf le span racine)
  • Un timestamp de début et de fin
  • Des attributs (clés-valeurs)
  • Un statut (OK, ERROR)
Trace "abc123" — requête POST /orders (durée : 234ms)
│
├── Span "api-gateway"           [0ms → 8ms]
│     method: POST, route: /orders
│
├── Span "order-service.create"  [8ms → 180ms]   ← span racine du service
│     orderId: ord-456
│     ├── Span "postgres.query"  [10ms → 130ms]  ← goulot d'étranglement
│     │     db.statement: "INSERT INTO orders..."
│     │     db.rows_affected: 1
│     └── Span "payment-service.charge" [131ms → 178ms]
│           rpc.method: Charge
│           payment.status: success
│
└── Span "notification.send"     [180ms → 234ms]
      notification.type: email
      notification.recipient: alice@exemple.fr

En regardant cette trace, tu identifies immédiatement que la requête PostgreSQL de 120ms est responsable de 51% de la latence totale.


Le contexte de trace et sa propagation

Pour qu'un span dans le service B soit rattaché à la trace du service A, le traceId et le spanId doivent être transmis entre les deux.

Le standard W3C TraceContext (RFC 7528) définit deux headers HTTP :

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             ^  ^                                ^                ^
             version  traceId (128 bits)          parentSpanId     flags
                                                  (64 bits)
tracestate: vendorA=value1,vendorB=value2

Propagation manuelle (avant OpenTelemetry) :

typescript// Service A — injecter le contexte dans la requête sortante
async function callOrderService(
  traceId: string,
  spanId: string,
  payload: unknown
): Promise<unknown> {
  const traceparent = `00-${traceId}-${spanId}-01`;

  return fetch("http://order-service/orders", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "traceparent": traceparent,
    },
    body: JSON.stringify(payload),
  });
}

// Service B — extraire le contexte de la requête entrante
function extractTraceContext(req: Request): { traceId: string; parentSpanId: string } | null {
  const traceparent = req.headers["traceparent"] as string;
  if (!traceparent) return null;

  const parts = traceparent.split("-");
  if (parts.length !== 4) return null;

  return { traceId: parts[1], parentSpanId: parts[2] };
}

En Python :

pythonimport re

def extract_trace_context(headers: dict) -> dict | None:
    traceparent = headers.get("traceparent")
    if not traceparent:
        return None

    parts = traceparent.split("-")
    if len(parts) != 4:
        return None

    return {"trace_id": parts[1], "parent_span_id": parts[2]}

def inject_trace_context(headers: dict, trace_id: str, span_id: str) -> dict:
    return {
        **headers,
        "traceparent": f"00-{trace_id}-{span_id}-01",
    }

En pratique, tu n'implémentes pas ça manuellement — OpenTelemetry le fait pour toi (voir l'article 04). Mais comprendre le mécanisme sous-jacent est indispensable pour déboguer les traces cassées.


Pourquoi les traces "cassent"

Une trace est cassée quand un span n'est pas rattaché au bon parent. Causes fréquentes :

1. Header non propagé. Un service intermédiaire (proxy, gateway) supprime le header traceparent au lieu de le transmettre.

2. Bibliothèque non instrumentée. Un appel HTTP via urllib (Python) ou node-fetch (Node.js) sans instrumentation OTel ne propage pas le contexte.

3. Contexte perdu dans un worker async. Un task queue (BullMQ, Celery) ne transmet pas automatiquement le contexte de trace à la tâche exécutée plus tard.

typescript// Problème : le contexte de trace est perdu quand la tâche est exécutée
queue.add("send-email", { orderId, email });

// Solution : sérialiser le contexte avec la tâche
import { context, propagation } from "@opentelemetry/api";

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

queue.add("send-email", { orderId, email, traceContext: carrier });

// Dans le worker : restaurer le contexte
queue.process("send-email", async (job) => {
  const ctx = propagation.extract(context.active(), job.data.traceContext);
  await context.with(ctx, async () => {
    // La trace est restaurée ici
    await sendEmail(job.data.email);
  });
});

Les stratégies de sampling

Tracer 100% des requêtes en production est rarement souhaitable — le coût en stockage et en performance devient prohibitif au-delà de quelques milliers de requêtes par seconde.

Head-based sampling (décision à l'entrée)

La décision de tracer ou non est prise au démarrage de la trace, avant de connaître son issue.

typescript// Tracer 10% des requêtes aléatoirement
const sampler = new TraceIdRatioBasedSampler(0.1);

// Toujours tracer les requêtes d'erreur — impossible avec head-based seul

Avantage : simple, faible overhead. Inconvénient : tu peux rater les traces d'erreur si elles ne sont pas samplées.

Tail-based sampling (décision à la fin)

La décision est prise après la complétion de la trace. Le collecteur peut décider de garder toutes les traces d'erreur ou celles dont la latence dépasse un seuil.

yaml# Configuration OpenTelemetry Collector — tail sampling
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: errors-policy
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow-requests-policy
        type: latency
        latency: { threshold_ms: 1000 }
      - name: probabilistic-policy
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

Avantage : garde les traces intéressantes (erreurs, lenteur). Inconvénient : nécessite un collecteur avec mémoire (le collecteur doit attendre la fin de la trace).

Règle pratique

  • Développement : 100% (tout tracer)
  • Production basse charge (< 100 req/s) : 100%
  • Production moyenne charge (100-1000 req/s) : tail-based sampling, toujours garder les erreurs
  • Production haute charge (> 1000 req/s) : head-based 5-10% + tail-based pour les erreurs

Les backends de traces

Backend Open source Hébergé Point fort
Jaeger Oui Oui (Jaegertracing.io) Simple à déployer, UI intuitive
Grafana Tempo Oui Oui (Grafana Cloud) Intégration Grafana + Loki
Zipkin Oui Non Léger, historique
Datadog APM Non Oui Corrélation logs+métriques+traces
Honeycomb Non Oui Observability-driven development

En self-hosted : Jaeger ou Grafana Tempo (s'intègre avec Prometheus + Loki pour une stack complète).


Attributs sémantiques standard

OpenTelemetry définit des attributs sémantiques standardisés — les utiliser permet aux outils d'interpréter automatiquement les traces.

typescript// HTTP
span.setAttribute("http.method", "POST");
span.setAttribute("http.url", "https://api.exemple.fr/orders");
span.setAttribute("http.status_code", 201);

// Base de données
span.setAttribute("db.system", "postgresql");
span.setAttribute("db.name", "orders");
span.setAttribute("db.statement", "INSERT INTO orders (...)");
span.setAttribute("db.rows_affected", 1);

// Messaging (Kafka, RabbitMQ)
span.setAttribute("messaging.system", "kafka");
span.setAttribute("messaging.destination", "orders");
span.setAttribute("messaging.operation", "publish");

// Métier (custom)
span.setAttribute("app.order.id", orderId);
span.setAttribute("app.order.total_cents", totalCents);

Résumé

Concept Définition
Trace Parcours complet d'une requête — identifiée par un traceId
Span Une opération dans la trace — spanId, parentSpanId, timestamps, attributs
Context propagation Transmission du traceId entre services via traceparent (W3C)
Head sampling Décision à l'entrée — simple mais perd les erreurs non samplées
Tail sampling Décision à la fin — garde les traces intéressantes, plus coûteux

Étape suivante : 04 — OpenTelemetry — instrumenter automatiquement logs, métriques et traces avec un seul SDK.


Sources

  • OpenTelemetry Authors. Traces. opentelemetry.io/docs/concepts/signals/traces/
  • W3C. Trace Context Recommendation. w3.org/TR/trace-context/ (Standard traceparent)
  • Majors, C., Fong-Jones, L., & Miranda, G. (2022). Observability Engineering, Chapitre 4 "Distributed Tracing". O'Reilly.
  • Shkuro, Y. (2019). Mastering Distributed Tracing. Packt Publishing.
  • Beyer, B., et al. (2016). Site Reliability Engineering, Chapitre 6. O'Reilly.

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.