Wir haben dargelegt, dass Anwendungslogs nicht der Cloud gehören sollten: die App emittiert, ein Agent stempelt die Cloud darauf, ein neutrales Backend speichert. Logs waren die einfache Hälfte. Eine Log-Zeile ist in sich geschlossen. Ein Trace nicht: er ist ein Baum aus Spans, den fünf Dienste auf drei Clouds erzeugen, zusammengenäht von einer ID, die jeden Hop dazwischen überleben muss, eine Queue und eine Funktion ohne HTTP eingeschlossen. Schafft die ID es nicht hinüber, haben Sie keinen Trace, sondern fünf unzusammenhängende Spans und dieselbe Korrelation per Auge um 02:40, die wir loswerden wollten.
Dies ist dieselbe Architektur, auf Tracing angewandt, mit den Teilen, die anders sind: Propagation, Sampling und wie die Abfrage aussieht, wenn es funktioniert.
Die drei Schichten, noch einmal
Die Anwendung nutzt das OpenTelemetry SDK und nichts Anbieterspezifisches. In Node ist das @opentelemetry/sdk-node mit Auto-Instrumentierung für HTTP, den Datenbanktreiber, das AWS SDK und den Queue-Client. Sie exportiert Spans über OTLP nach localhost:4317. Sie hat keine Ahnung, ob der Collector am anderen Ende nach Tempo, X-Ray oder in eine Datei weiterleitet.
// tracing.ts, per --require vor der App geladen
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 ist das einzige Attribut, das die App setzt. Alles darüber, wo sie läuft, kommt später dazu.
Der Collector ist dieselbe DaemonSet-Konfiguration wie für Logs mit einer zusätzlichen traces-Pipeline. Der resourcedetection-Prozessor stempelt cloud.provider, cloud.region und k8s.cluster.name auf jeden Span, genau wie auf jede Log-Zeile, aus demselben Metadata-Endpunkt, ohne jede Verzweigung zwischen EKS, GKE und AKS.
Das Backend ist Grafana Tempo: OTLP hinein, TraceQL heraus, Object Storage darunter, kein Index zu dimensionieren. Tempo und Loki stehen nebeneinander in derselben Grafana, und genau das macht „von dieser Log-Zeile zu ihrem Trace springen“ zu einem Klick statt zu Copy-Paste.
Der schwere Teil: die ID muss die Lücken überqueren
Innerhalb eines Dienstes verwaltet das SDK den Kontext automatisch. Zwischen Diensten über HTTP wird der W3C-Header traceparent von der Client-Instrumentierung injiziert und von der Server-Instrumentierung extrahiert, und es funktioniert einfach. Die Lücken sind alles, was kein synchroner HTTP-Aufruf ist.
Queues. SQS, Pub/Sub und Service Bus tragen keine HTTP-Header. Der Producer muss den Kontext irgendwo ablegen, wo der Consumer ihn findet, und der Consumer muss ihn herausholen und seinen Span als Kind starten (oder, ehrlicher, als Link, denn der Consumer läuft später und womöglich in einem Batch). Die AWS-SDK-Instrumentierung erledigt das für SQS über Nachrichtenattribute, wenn Sie sie verwenden; für andere Clients schreiben Sie zehn Zeilen:
// Producer
const carrier: Record<string, string> = {};
propagation.inject(context.active(), carrier);
await queue.send({ body, attributes: { traceparent: carrier.traceparent } });
// Consumer
const parent = propagation.extract(context.active(), { traceparent: msg.attributes.traceparent });
const span = tracer.startSpan('process order', { links: [{ context: trace.getSpanContext(parent)! }] }, parent);
Lambda. Die Funktion hat keinen langlebigen Prozess, in dem das SDK leben könnte, also umhüllt der OpenTelemetry-Lambda-Layer den Handler, extrahiert den Kontext aus dem Event (HTTP-Header bei API Gateway, Nachrichtenattribute bei SQS oder ein traceparent-Feld, das wir bei direkten Aufrufen selbst in den Payload legen) und flusht die Spans, bevor die Funktion einfriert. Der Flush ist das Detail, das zählt: ohne ihn geht der letzte Span jedes Aufrufs verloren.
HTTP über Cloud-Grenzen. Nichts Besonderes. traceparent ist ein Header wie jeder andere, und die Load Balancer aller drei Clouds reichen ihn durch. Das Einzige, was zu prüfen ist: Ihr API-Gateway oder Ihre WAF darf unbekannte Header nicht entfernen; eine von unseren tat es, eine Woche lang, und jeder Trace endete am Rand.
Beim Sampling wohnt der Egress
Traces sind pro Request größer als Logs: ein einziger Bestellablauf erzeugt vierzig Spans mit Attributen. Im Maßstab würde es mehr kosten, sie alle über Cloud-Grenzen zu schicken, als es die Logs taten. Die Antwort liegt im Collector, und es muss Tail-Sampling sein, kein Head-Sampling: zu Beginn eines Requests kann man nicht entscheiden, ob er interessant wird.
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 } }
Jeden Trace mit Fehler behalten, jeden Trace langsamer als zwei Sekunden, und einen von zehn der übrigen. Das hat unser Trace-Volumen um etwa 85 % gesenkt, und in sechs Monaten haben wir nie einen Trace vermisst, der verworfen worden war: die interessanten sind per Konstruktion die, die bleiben.
Es gibt einen Haken speziell bei Multi-Cloud. Tail-Sampling braucht alle Spans eines Trace bei derselben Collector-Instanz, um zu entscheiden, und unsere Spans entstehen auf drei Clustern. Die Lösung ist ein zweistufiger Collector: das DaemonSet pro Node macht nur Resource Detection und Batching und leitet an ein kleines zentrales Collector-Deployment weiter (pro Cloud oder eines gemeinsam), das das Tail-Sampling übernimmt, mit einem Load Balancer, der nach Trace-ID routet. Zehn weitere Zeilen Konfiguration und eine Komponente, die nur existiert, weil der Trace eine Grenze überquert.
Wie die Abfrage aussieht
Der Trace des 02:40-Vorfalls, in TraceQL:
{ trace:id = "4bf92f3577b34da6a3ce929d0e0e4736" }
Jeder fehlgeschlagene Bestellablauf, der in der letzten Stunde den Azure-Cluster berührt hat:
{ resource.service.name = "billing" && resource.cloud.provider = "azure" && status = error }
Bestellungen langsamer als zwei Sekunden, bei denen der langsame Span auf GCP lag, also die Abfrage, die „der Worker ist langsam, nicht die API“ findet:
{ resource.service.name = "api" && duration > 2s } >> { resource.cloud.provider = "gcp" && duration > 1500ms }
Und von jeder Log-Zeile in Loki mit einer trace_id öffnet ein Klick den Trace in Tempo, weil beide in derselben Grafana liegen und beide dieselbe ID vom selben SDK bekommen haben.
Was nativ bleibt
X-Ray, Cloud Trace und Application Insights sind gute Produkte, und jedes ist an eine Cloud gebunden. Wir nutzen sie für Anwendungs-Traces aus demselben Grund nicht, aus dem wir CloudWatch nicht für Anwendungslogs nutzen. Wo sie sich verdient machen, ist innerhalb verwalteter Dienste, die wir nicht selbst instrumentieren: eine AWS-Step-Functions-Ausführung oder eine API-Gateway-Integration erzeugt kostenlos X-Ray-Segmente, und die sind nützlich, um diesen Dienst zu debuggen. Sie müssen sich nicht dem Anwendungs-Trace anschließen, und sie dazu zu zwingen ist mehr Arbeit, als es wert ist.
Das OTel SDK kann X-Rays Header-Format neben dem W3C-Format propagieren, falls sich die beiden Welten an einer Grenze treffen müssen. Wir haben es einmal eingeschaltet, für ein API Gateway, das darauf bestand, und wieder ausgeschaltet, als das Gateway umzog.
Die Kurzfassung
Dieselbe Collector-Konfiguration, eine Pipeline mehr. Dieselben Resource-Attribute, ein Backend mehr. Die neue Arbeit liegt an den Lücken: Inject beim Producer, Extract beim Consumer, Flush bevor eine Funktion einfriert, Tail-Sampling in einem Collector, der den ganzen Trace sieht. Ist das erledigt, ist „warum ist diese Bestellung langsam“ eine Abfrage, die die Antwort mit der Cloud als Label zurückgibt, und ein Dienst, der die Cloud wechselt, ändert den Wert dieses Labels und sonst nichts.
Wenn Ihre Traces an einer Queue oder an einer Cloud-Grenze abreißen, wir haben diese Lücke schon einmal zugenäht.