Nous avons défendu l'idée que les logs applicatifs ne devraient pas appartenir au cloud : l'application émet, un agent y estampille le cloud, un backend neutre les stocke. Les logs étaient la moitié facile. Une ligne de log se suffit à elle-même. Une trace, non : c'est un arbre de spans produits par cinq services sur trois clouds, cousus ensemble par un identifiant qui doit survivre à chaque saut entre eux, y compris une file d'attente et une fonction qui ne parle pas HTTP. Si l'identifiant ne passe pas, vous n'avez pas une trace, vous avez cinq spans sans lien et la même corrélation à l'œil de 02 h 40 dont nous voulions nous débarrasser.
C'est la même architecture appliquée au tracing, avec les parties qui diffèrent : la propagation, l'échantillonnage, et à quoi ressemble la requête quand ça marche.
Les trois couches, à nouveau
L'application utilise le SDK OpenTelemetry et rien de spécifique à un fournisseur. En Node, c'est @opentelemetry/sdk-node avec l'auto-instrumentation pour HTTP, le pilote de base de données, le SDK AWS et le client de file. Elle exporte des spans en OTLP vers localhost:4317. Elle n'a aucune idée si le collecteur à l'autre bout transmet à Tempo, X-Ray ou un fichier.
// tracing.ts, chargé avec --require avant l'application
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { Resource } from '@opentelemetry/resources';
new NodeSDK({
resource: new Resource({ 'service.name': process.env.OTEL_SERVICE_NAME }),
traceExporter: new OTLPTraceExporter({ url: 'http://localhost:4317' }),
instrumentations: [getNodeAutoInstrumentations()],
}).start();
service.name est le seul attribut que l'application définit. Tout ce qui concerne où elle tourne est ajouté plus tard.
Le collecteur est la même configuration de DaemonSet que pour les logs avec un pipeline traces en plus. Le processeur resourcedetection estampille cloud.provider, cloud.region et k8s.cluster.name sur chaque span, exactement comme sur chaque ligne de log, depuis le même endpoint de métadonnées, sans aucune ramification entre EKS, GKE et AKS.
Le backend est Grafana Tempo : OTLP en entrée, TraceQL en sortie, stockage objet en dessous, aucun index à dimensionner. Tempo et Loki sont côte à côte dans la même Grafana, et c'est ce qui fait de « sauter de cette ligne de log à sa trace » un clic plutôt qu'un copier-coller.
La partie difficile : l'identifiant doit franchir les trous
Au sein d'un service, le SDK gère le contexte automatiquement. Entre services en HTTP, l'en-tête W3C traceparent est injecté par l'instrumentation cliente et extrait par l'instrumentation serveur, et ça marche tout seul. Les trous, c'est tout ce qui n'est pas un appel HTTP synchrone.
Les files. SQS, Pub/Sub et Service Bus ne transportent pas d'en-têtes HTTP. Le producteur doit mettre le contexte quelque part où le consommateur le trouvera, et le consommateur doit l'en sortir et démarrer son span comme enfant (ou, plus honnêtement, comme lien, puisque le consommateur tourne plus tard et peut-être en lot). L'instrumentation du SDK AWS le fait pour SQS si vous l'utilisez, via les attributs de message ; pour les autres clients, vous écrivez dix lignes :
// producteur
const carrier: Record<string, string> = {};
propagation.inject(context.active(), carrier);
await queue.send({ body, attributes: { traceparent: carrier.traceparent } });
// consommateur
const parent = propagation.extract(context.active(), { traceparent: msg.attributes.traceparent });
const span = tracer.startSpan('process order', { links: [{ context: trace.getSpanContext(parent)! }] }, parent);
Lambda. La fonction n'a pas de processus à longue durée de vie où le SDK puisse vivre, donc la couche OpenTelemetry pour Lambda enveloppe le handler, extrait le contexte de l'événement (en-têtes HTTP pour API Gateway, attributs de message pour SQS, ou un champ traceparent que nous mettons nous-mêmes dans la charge utile pour les invocations directes), et vide les spans avant que la fonction ne gèle. Le vidage est le détail qui compte : sans lui, le dernier span de chaque invocation est perdu.
HTTP entre clouds. Rien de spécial. traceparent est un en-tête comme un autre et les équilibreurs de charge des trois clouds le laissent passer. La seule chose à vérifier, c'est que votre passerelle d'API ou votre WAF ne supprime pas les en-têtes inconnus ; l'une des nôtres le faisait, pendant une semaine, et chaque trace s'arrêtait à la bordure.
L'échantillonnage, c'est là que vit l'egress
Les traces sont plus grosses que les logs par requête : un seul parcours de commande produit quarante spans avec attributs. À l'échelle, les expédier toutes à travers les frontières de cloud coûterait plus que les logs. La réponse est dans le collecteur, et ce doit être un échantillonnage en fin de trace, pas en début : vous ne pouvez pas décider au départ d'une requête si elle va être intéressante.
processors:
tail_sampling:
decision_wait: 10s
policies:
- { name: errors, type: status_code, status_code: { status_codes: [ERROR] } }
- { name: slow, type: latency, latency: { threshold_ms: 2000 } }
- { name: baseline, type: probabilistic, probabilistic: { sampling_percentage: 10 } }
Garder chaque trace avec une erreur, chaque trace de plus de deux secondes, et une sur dix du reste. Ça a réduit notre volume de traces d'environ 85 % et, en six mois, nous n'avons jamais voulu une trace qui avait été écartée : les intéressantes sont, par construction, celles qui sont conservées.
Il y a un piège propre au multi-cloud. L'échantillonnage en fin de trace a besoin que tous les spans d'une trace arrivent à la même instance de collecteur pour prendre une décision, et nos spans sont produits sur trois clusters. La solution est un collecteur à deux niveaux : le DaemonSet par nœud ne fait que la détection de ressources et le regroupement, et transmet à un petit déploiement de collecteur central (par cloud, ou un seul partagé) qui fait l'échantillonnage, avec un équilibreur qui route par identifiant de trace. Dix lignes de configuration de plus, et un composant qui existe uniquement parce que la trace franchit une frontière.
À quoi ressemble la requête
La trace de l'incident de 02 h 40, en TraceQL :
{ trace:id = "4bf92f3577b34da6a3ce929d0e0e4736" }
Chaque parcours de commande en échec qui a touché le cluster Azure dans la dernière heure :
{ resource.service.name = "billing" && resource.cloud.provider = "azure" && status = error }
Les commandes de plus de deux secondes où le span lent était sur GCP, c'est-à-dire la requête qui trouve « c'est le worker qui est lent, pas l'API » :
{ resource.service.name = "api" && duration > 2s } >> { resource.cloud.provider = "gcp" && duration > 1500ms }
Et depuis n'importe quelle ligne de log dans Loki avec un trace_id, un clic ouvre la trace dans Tempo, parce que les deux sont dans la même Grafana et que les deux ont reçu le même identifiant du même SDK.
Ce qui reste natif
X-Ray, Cloud Trace et Application Insights sont de bons produits et chacun est verrouillé sur un seul cloud. Nous ne les utilisons pas pour les traces applicatives pour la même raison que nous n'utilisons pas CloudWatch pour les logs applicatifs. Là où ils méritent leur place, c'est à l'intérieur des services managés que nous n'instrumentons pas nous-mêmes : une exécution AWS Step Functions ou une intégration API Gateway produit des segments X-Ray gratuitement, et ils sont utiles pour déboguer ce service-là. Ils n'ont pas besoin de rejoindre la trace applicative, et les y forcer demande plus de travail que ça n'en vaut.
Le SDK OTel peut propager le format d'en-tête de X-Ray à côté de celui du W3C si vous avez besoin que les deux mondes se rencontrent à une frontière. Nous l'avons activé une fois, pour une API Gateway qui insistait, et désactivé quand la passerelle a déménagé.
La version courte
Même configuration de collecteur, un pipeline de plus. Mêmes attributs de ressource, un backend de plus. Le nouveau travail est aux trous : inject chez le producteur, extract chez le consommateur, vidage avant qu'une fonction ne gèle, échantillonnage en fin de trace dans un collecteur qui voit toute la trace. Une fois cela fait, « pourquoi cette commande est-elle lente » est une requête qui renvoie la réponse avec le cloud attaché comme label, et déplacer un service entre clouds change la valeur de ce label et rien d'autre.
Si vos traces s'arrêtent à une file ou à une frontière de cloud, nous avons déjà recousu ce trou.