# Auch Ihre Traces sollten nicht wissen, in welcher Cloud sie laufen

Wir haben dargelegt, dass [Anwendungslogs nicht der Cloud gehören sollten](/de/blog/your-logs-should-not-know-which-cloud): 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.

```ts
// 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](/de/blog/your-logs-should-not-know-which-cloud) 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.

<div class="article-figure">
<svg viewBox="0 0 900 260" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Ein Request über drei Clouds. Der Browser ruft die API auf EKS per HTTP mit traceparent-Header. Die API publiziert nach SQS und injiziert den Trace-Kontext in ein Nachrichtenattribut. Der Order-Worker auf GKE konsumiert die Nachricht, extrahiert den Kontext und ruft den Billing-Dienst auf AKS per HTTP mit traceparent. Billing ruft eine Lambda auf; die Lambda-Instrumentierung extrahiert den Kontext aus dem Event. Alle Spans teilen eine Trace-ID und landen in einem Tempo.">
<defs><marker id="arrT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="15" y="60" width="130" height="64" rx="10" fill="#151b2e" stroke="#9aa3c7" stroke-width="1.5"/><text x="80" y="86" text-anchor="middle" fill="#f1f3ff" font-weight="700">Browser</text><text x="80" y="106" text-anchor="middle" fill="#9aa3c7" font-size="11">POST /orders</text>
<line x1="147" y1="92" x2="188" y2="92" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrT)"/><text x="167" y="80" text-anchor="middle" fill="#4fffb0" font-size="10">HTTP</text>
<rect x="190" y="60" width="140" height="64" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="260" y="80" text-anchor="middle" fill="#f1f3ff" font-weight="700">api</text><text x="260" y="98" text-anchor="middle" fill="#ffd166" font-size="11">EKS · aws</text><text x="260" y="114" text-anchor="middle" fill="#9aa3c7" font-size="10">traceparent extrahiert</text>
<line x1="332" y1="92" x2="373" y2="92" stroke="#ff6b8a" stroke-width="1.5" marker-end="url(#arrT)"/><text x="352" y="80" text-anchor="middle" fill="#ff6b8a" font-size="10">SQS</text>
<rect x="375" y="60" width="140" height="64" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5" stroke-dasharray="5,3"/><text x="445" y="80" text-anchor="middle" fill="#f1f3ff" font-weight="700">Queue</text><text x="445" y="98" text-anchor="middle" fill="#ff6b8a" font-size="11">die Lücke</text><text x="445" y="114" text-anchor="middle" fill="#9aa3c7" font-size="10">Kontext im Nachrichtenattribut</text>
<line x1="517" y1="92" x2="558" y2="92" stroke="#ff6b8a" stroke-width="1.5" marker-end="url(#arrT)"/>
<rect x="560" y="60" width="140" height="64" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="630" y="80" text-anchor="middle" fill="#f1f3ff" font-weight="700">order-worker</text><text x="630" y="98" text-anchor="middle" fill="#ffd166" font-size="11">GKE · gcp</text><text x="630" y="114" text-anchor="middle" fill="#9aa3c7" font-size="10">Kontext extrahiert, Span verlinkt</text>
<line x1="702" y1="92" x2="743" y2="92" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrT)"/><text x="722" y="80" text-anchor="middle" fill="#4fffb0" font-size="10">HTTP</text>
<rect x="745" y="60" width="140" height="64" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="815" y="80" text-anchor="middle" fill="#f1f3ff" font-weight="700">billing</text><text x="815" y="98" text-anchor="middle" fill="#ffd166" font-size="11">AKS · azure</text><text x="815" y="114" text-anchor="middle" fill="#9aa3c7" font-size="10">→ Lambda, Kontext im Event</text>
<line x1="80" y1="126" x2="80" y2="190" stroke="#2a3150" stroke-dasharray="3,3"/><line x1="815" y1="126" x2="815" y2="190" stroke="#2a3150" stroke-dasharray="3,3"/>
<rect x="80" y="190" width="735" height="40" rx="8" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="447" y="215" text-anchor="middle" fill="#7b8cff" font-weight="700">eine trace_id · 4bf92f3577b34da6a3ce929d0e0e4736 · jeder Span in einem Tempo</text>
<text x="450" y="252" text-anchor="middle" fill="#9aa3c7">Drei Clouds, zwei Protokolle, eine Queue. Die ID überlebt, weil jede Lücke ein explizites Inject auf der einen und ein Extract auf der anderen Seite hat.</text>
</g>
</svg>
</div>

**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:

```ts
// 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.

```yaml
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.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Wasserfallansicht des Cloud-übergreifenden Trace, wie Grafana ihn darstellt. Wurzel-Span POST /orders auf api, aws, 2,9 Sekunden. Kind-Span SQS publish, 12 Millisekunden. Verlinkter Span process order auf order-worker, gcp, 90 Millisekunden später beginnend, 2,7 Sekunden. Sein Kind-Span HTTP POST billing auf billing, azure, 2,6 Sekunden, Status error, connection reset. Der Fehler liegt sichtbar im Azure-Span und die Zeit wird sichtbar mit Warten darauf verbracht.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="24" fill="#f1f3ff" font-size="14" font-weight="700">Trace 4bf9…4736 · 2,9 s · 4 Dienste · 3 Clouds</text>
<text x="20" y="58" fill="#9aa3c7">api · aws</text><rect x="200" y="46" width="660" height="16" rx="3" fill="#7b8cff"/><text x="210" y="58" fill="#0d1120" font-size="11">POST /orders · 2,9 s</text>
<text x="20" y="88" fill="#9aa3c7">api · aws</text><rect x="212" y="76" width="8" height="16" rx="2" fill="#7b8cff"/><text x="226" y="88" fill="#9aa3c7" font-size="11">SQS publish · 12 ms</text>
<text x="20" y="118" fill="#9aa3c7">order-worker · gcp</text><rect x="240" y="106" width="610" height="16" rx="3" fill="#4fffb0"/><text x="250" y="118" fill="#0d1120" font-size="11">process order · 2,7 s · verlinkt aus SQS-Nachricht</text>
<text x="20" y="148" fill="#9aa3c7">billing · azure</text><rect x="262" y="136" width="580" height="16" rx="3" fill="#ff6b8a"/><text x="272" y="148" fill="#0d1120" font-size="11">HTTP POST /charge · 2,6 s · status=error · connection reset by peer</text>
<text x="20" y="178" fill="#9aa3c7">billing-fn · azure</text><rect x="270" y="166" width="20" height="16" rx="2" fill="#9aa3c7"/><text x="296" y="178" fill="#9aa3c7" font-size="11">Lambda-Aufruf · 40 ms · Kontext aus Event</text>
<line x1="200" y1="196" x2="860" y2="196" stroke="#2a3150"/><text x="200" y="212" fill="#9aa3c7" font-size="10">0 s</text><text x="860" y="212" text-anchor="end" fill="#9aa3c7" font-size="10">2,9 s</text>
<text x="450" y="226" text-anchor="middle" fill="#9aa3c7">Die Zeit geht im Azure-Span drauf. Der Fehler liegt im Azure-Span. Niemand musste das Azure-Portal öffnen, um das zu erfahren.</text>
</g>
</svg>
</div>

## 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](/contact).
