Skip to content

ADR 027 — Tracing distribué : Tempo + OpenTelemetry Collector (2026-06-27)

Statut

Validé live (2026-06-27). Issues #107 (instrumentation FastAPI) et #108 (backend Tempo + collector + datasource). 3e et dernier pilier d'observabilité, après les métriques (#74, ADR 014) et les logs (#75, ADR 015). Validation from-scratch : Tempo + Collector planifiés sur le nœud observability Spot, trace de bout en bout visible dans Tempo (span HTTP GET /healthz/ready + span enfant SQLAlchemy SELECT 1, attributs DB + serveur RDS), corrélation tracesToLogsV2 opérationnelle. 3 trous corrigés en live : egress NetworkPolicy manquante vers le Collector (!208), Tempo OOMKilled par le memBallastSizeMbs du chart (!209 puis !210, voir le guide comprendre/tracing.md). Décision auto-instrumentation SQLAlchemy retenue au lieu des spans custom (vraies requêtes tracées). Détail pédagogique : guide tracing.

Contexte

L'observabilité du projet couvre deux piliers sur trois :

  • Métriques (kube-prometheus-stack, ADR 014) : que ça casse, combien.
  • Logs (Loki + Alloy, ADR 015) : pourquoi sur un pod donné.

Il manque le tracing distribué : le chemin d'une requête à travers le système, avec le temps passé à chaque étape (handler HTTP, requête DB). Sur un seul workload FastAPI le bénéfice paraît modeste, mais le tracing complète la corrélation (métrique → trace → log) et prépare l'arrivée d'un 2e workload (frontend #109). C'est aussi la brique d'observabilité la plus mal comprise en entretien : l'implémenter proprement est un atout portfolio.

Contraintes identiques aux deux autres piliers : cluster EKS éphémère, GitOps via ArgoCD (app-of-apps), rétention courte assumée pour le coût, Grafana comme unique interface de consultation. Capacité disponible après #114 (ADR 026) : ~6Gi libres sur le nœud observability Spot, ce qui accueille la stack tracing sans nouvelle capacité.

Décision 1 — Backend Grafana Tempo (vs Jaeger)

Le backend de traces est Grafana Tempo, pas Jaeger.

Critère Tempo Jaeger
Datasource Grafana Native, 1re classe Plugin, UI séparée
Interface de consultation Grafana (déjà là) UI Jaeger dédiée (redondante)
Stockage Object store / filesystem Cassandra / Elasticsearch / badger
Cohérence stack Aligné Loki/Prometheus (écosystème Grafana) Écosystème distinct
Corrélation logs/métriques tracesToLogs + exemplars natifs Manuelle

Tempo n'indexe que l'ID de trace (pas les attributs), ce qui le rend très léger en stockage. La consultation se fait dans Grafana, déjà exposé et SSO (#106) : pas d'UI supplémentaire à déployer, sécuriser et exposer. Jaeger imposerait une seconde interface redondante avec Grafana. Tempo prolonge l'écosystème Grafana déjà en place (Loki, Prometheus, Alloy).

Décision 2 — Tempo monolithic + stockage filesystem

Tempo est déployé en mode monolithic (un seul process, chart grafana/tempo), storage.trace.backend: local sur un emptyDir (/var/tempo), pas de PVC ni d'object store. Rétention courte (24h, alignée Prometheus/Loki).

C'est l'exact analogue de Loki SingleBinary (ADR 015) : sur un cluster éphémère mono-cluster, le mode tempo-distributed (ingester / distributor / querier / compactor séparés + object store) serait absurde. Les traces meurent au teardown, comme les métriques et les logs. Une prod permanente utiliserait un bucket S3 + tempo-distributed.

Décision 3 — Pipeline via OpenTelemetry Collector central (vs export direct)

Le chemin des traces est FastAPI → OTel Collector → Tempo, pas un export direct FastAPI → Tempo (Tempo a pourtant un receiver OTLP natif).

FastAPI (SDK OTel, OTLP/HTTP)
        │
        ▼
OTel Collector (Deployment, ns tracing)   ← receiver OTLP, processors batch
        │                                    point de sampling / enrichissement
        ▼
Tempo (monolithic, ns tracing)            ← stockage + requête
        │
        ▼
Grafana (datasource Tempo)                ← consultation + corrélation

Le Collector central (pattern gateway, un Deployment) ajoute un composant, mais c'est l'architecture de production réelle :

  • Découplage : l'app ne connaît qu'un endpoint OTLP stable ; changer ou doubler le backend (Tempo + un export externe) ne touche pas le code.
  • Point de contrôle unique : batch, retry, et le jour venu sampling et enrichissement d'attributs se configurent au Collector, pas dans chaque app.
  • Idiomatique : c'est le déploiement que recruteurs et prod attendent.

L'export direct serait plus simple (un composant de moins) mais ferait l'impasse sur la brique la plus structurante du tracing. Le surcoût (1 pod léger sur le nœud obs qui a ~6Gi libres) est négligeable au regard de la valeur pédagogique.

Décision 4 — Full sampling (100 %), tail-sampling documenté

Toutes les traces sont conservées (parentbased_always_on). Le volume est trivial (un seul workload, trafic de projet) et la rétention courte : échantillonner n'apporterait rien. Le tail-sampling (garder les traces lentes ou en erreur, jeter le bruit nominal) est la stratégie de prod ; il se brancherait sur le tail_sampling processor du Collector sans toucher l'app (c'est tout l'intérêt de la Décision 3). On le documente comme évolution, on ne le déploie pas.

Décision 5 — Auto-instrumentation FastAPI + spans custom, export OTLP

L'app est instrumentée via opentelemetry-instrumentation-fastapi (auto-instrumentation : un span par requête HTTP, propagation du contexte W3C traceparent) complétée d'un ou deux spans custom sur le handler DB pour montrer le temps passé hors HTTP. Export OTLP/HTTP (port 4318) vers le Collector, configuré par variables d'environnement standard (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME).

Conséquences pour l'app (traitées en #107) :

  • Nouvelles dépendances (opentelemetry-sdk, -instrumentation-fastapi, -exporter-otlp) → l'image change → re-scan Trivy obligatoire (nouvelles CVE potentielles dans la chaîne OTel) et SBOM régénéré.
  • Coexistence avec prometheus-fastapi-instrumentator (#73) : les deux hookent le pipeline ASGI. À valider qu'ils ne se marchent pas dessus, dans la lignée de la dette starlette/instrumentator (#95/#96) — on ne bumpe pas starlette pour OTel.
  • OTLP/HTTP plutôt que gRPC : pas de souci de proxy/HTTP2 à travers le cluster, plus simple à déboguer (curl-able).

Décision 6 — Namespace tracing dédié, placement nœud observability

Tempo et le Collector vivent dans un namespace tracing dédié, séparé de monitoring (métriques) et logging (logs) : séparation des responsabilités lisible, conventionnelle. Les deux pods portent le nodeSelector workload=observability + la toleration du taint Spot (pattern #114, ADR 026) : le tracing est de l'observabilité jetable, il atterrit avec le reste de la stack obs sur le nœud Spot, hors du chemin de trafic critique (core). Cross-namespace gratuit (pas de NetworkPolicy sur ces ns).

Décision 7 — Corrélation des trois piliers dans Grafana

La datasource Tempo est déclarée côté Grafana via le sidecar datasources du kube-prometheus-stack (ConfigMap labellisée grafana_datasource: "1" dans le ns monitoring, comme Loki en ADR 015), URL http://tempo.tracing.svc.cluster.local:3200. Elle câble la corrélation :

  • Métrique → trace : exemplars Prometheus (un traceID attaché à un point de métrique) → saut direct vers la trace dans Tempo.
  • Trace → logs : tracesToLogsV2 sur la datasource Tempo → pivot vers les logs Loki du même pod/intervalle.

On obtient le triangle métrique ↔ trace ↔ log dans une seule interface (Grafana), la vraie valeur d'une stack d'observabilité unifiée.

Pièges anticipés

  • Double instrumentation ASGI : opentelemetry-instrumentation-fastapi et prometheus-fastapi-instrumentator cohabitent. À vérifier en #107 (pas de régression /metrics, pas de bump starlette imposé).
  • Placement réel : Tempo + Collector consomment de la mémoire sur le nœud obs. ~6Gi libres mesurés après #114, mais à surveiller au prochain aws-start (requests honnêtes, pas de plancher-jeton).
  • Rendu ≠ runtime (INC-055) : helm template valide le rendu des charts, pas l'ingestion réelle. La validation des traces de bout en bout est live.
  • OTLP endpoint : l'app pousse vers le Service du Collector (http://otel-collector.tracing.svc.cluster.local:4318), pas vers Tempo directement.

Conséquences

  • GitOps : 2 nouvelles Applications ArgoCD dans k8s/platform/argocd-apps/ (tempo Helm, otel-collector Helm), + la datasource Tempo dans le kustomize monitoring. Pas de modif Ansible (ArgoCD owne les addons obs).
  • Empreinte : +2 pods (Tempo + Collector) sur le nœud observability.
  • Pas de PVC ni d'ELB : teardown par cascade du root-app, rien d'anti-INC-016.
  • Image app modifiée : #107 déclenche un nouveau build + scan Trivy + SBOM.
  • Séquence de livraison : ADR (ce document) → MR backend #108 (Tempo + Collector + datasource) → MR app #107 (instrumentation) → session live commune → MR doc de clôture (Closes #107 + Closes #108, ADR validé live).

Évolution possible

  • Tail-sampling au Collector (garder lent/erreur) quand le volume grossit.
  • Object storage S3 + tempo-distributed si le cluster devenait permanent.
  • Span metrics : le metrics-generator de Tempo dérive des métriques RED depuis les traces (latence/erreur par service) → alimente Prometheus sans instrumentation supplémentaire.
  • Logs → trace : injecter le trace_id dans les logs applicatifs pour le pivot inverse (log → trace), en complément du tracesToLogsV2.

Addendum — Service graph + fix OOM boot (#117 + #125, 2026-07-03)

Réalise l'évolution « Span metrics » anticipée ci-dessus, couplée à un correctif du memBallastSizeMbs (!210) : l'OOM boot ponctuel constaté le 2026-06-27 (tempo-0 exit 137 pendant la fenêtre de démarrage, steady-state stable à 46Mi) n'était pas entièrement réglé par la désactivation du ballast — le pic de spans au boot (sampling 100 %, probes + scrape tracés dès le premier instant) restait un facteur aggravant.

Metrics-generator Tempo : service_graphs + span_metrics

Le metrics-generator de Tempo (tempo.metricsGenerator, désactivé par défaut dans le chart) dérive des métriques Prometheus depuis les traces déjà ingérées, sans toucher au code applicatif ni à l'instrumentation (#107 reste inchangé) :

  • service_graphs : construit la topologie de service (qui appelle qui, latence par arête) à partir de la structure parent/enfant des spans. Sur ce projet, un seul service applicatif (fastapi) + sa DB (fastapi_db, span SQLAlchemy) — le graphe est minimal mais démontre le mécanisme (le portfolio vaut pour la brique, pas la taille du graphe).
  • span_metrics : dérive des métriques RED (Rate/Errors/Duration) par span, en complément des métriques HTTP déjà exposées par prometheus-fastapi-instrumentator (#73) — source différente (traces vs middleware), utile en recoupement.

Les deux processeurs écrivent en remote_write vers le Service Prometheus du chart kube-prometheus-stack (kps-prometheus.monitoring.svc.cluster.local:9090, nom confirmé par helm template sur les values réelles du projet — le fullnameOverride: kps du chart raccourcit le nom par défaut). Ce chemin exige d'activer explicitement la réception remote_write côté Prometheus (enableRemoteWriteReceiver: true, off par défaut : le chart n'ouvre pas un endpoint d'écriture sans opt-in). Exemplars (enableFeatures: [exemplar-storage], lien direct métrique → trace) posés en bonus, pas un critère de done — Prometheus les expose derrière un feature flag non stable, sans risque sur un cluster éphémère.

Côté Grafana, la datasource Tempo gagne jsonData.serviceMap.datasourceUid: prometheus (uid de la datasource Prometheus par défaut du chart, confirmé par rendu) et jsonData.nodeGraph.enabled: true : c'est ce qui active l'onglet Node Graph dans l'explorateur de traces Grafana.

Fix OOM boot : réduire le volume de spans à la source, pas la limite mémoire

Plutôt que de rebumper la limite mémoire de Tempo (déjà remontée à 512Mi le 2026-06-27), la mitigation retenue agit en amont, côté application : OTEL_PYTHON_EXCLUDED_URLS=healthz,metrics sur le Deployment FastAPI. Cette variable est lue nativement par opentelemetry-instrumentation-fastapi (mécanisme du SDK, confirmé en inspectant le paquet installé) — zéro changement de code dans app/tracing.py. Avec un sampling à 100 % (Décision 4), chaque probe liveness/readiness (10s) et chaque scrape Prometheus produisait un span HTTP tracé comme du trafic réel : sur un cluster qui vient de démarrer, ce bruit concentré dans la première minute contribuait au pic mémoire observé. Exclure ces deux routes réduit le volume de spans ingérés par Tempo (et donc traités par le metrics-generator) sans toucher au sampling ni à la limite mémoire — sert les deux tickets à la fois (#117 et #125), comme prévu au cadrage du 2026-07-02.

Validation

  • Hors infra (MR1) : helm template sur grafana-community/tempo et prometheus-community/kube-prometheus-stack avec les values réelles du repo (4 et 103 manifests rendus, schéma metricsGenerator/enableRemoteWriteReceiver accepté par le chart), kustomize build k8s/base + kube-linter (0 erreur) sur le Deployment modifié.
  • Live (MR2) ✅ validé le 2026-07-04 : service graph visible dans l'onglet Node Graph de Grafana (fastapi → fastapi_db), tempo-0 et otel-collector Running sans redémarrage depuis le boot (RESTARTS: 0, pas d'OOM). Closes #117 + Closes #125.

Date addendum : 2026-07-03 Issues : #117, #125


Date : 2026-06-27 Sprint : 5 Issues : #107, #108