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
traceIDattaché à un point de métrique) → saut direct vers la trace dans Tempo. - Trace → logs :
tracesToLogsV2sur 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-fastapietprometheus-fastapi-instrumentatorcohabitent. À 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 templatevalide 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/(tempoHelm,otel-collectorHelm), + la datasource Tempo dans le kustomizemonitoring. 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-distributedsi le cluster devenait permanent. - Span metrics : le
metrics-generatorde 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_iddans les logs applicatifs pour le pivot inverse (log → trace), en complément dutracesToLogsV2.
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 parprometheus-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 templatesurgrafana-community/tempoetprometheus-community/kube-prometheus-stackavec les values réelles du repo (4 et 103 manifests rendus, schémametricsGenerator/enableRemoteWriteReceiveraccepté 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-0etotel-collectorRunningsans 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