Technischer Leitfaden

OpenTelemetry in der Produktion: Traces, Context und was wirklich zählt

Produktions-OpenTelemetry-Patterns. Context Propagation über Queues und Worker, LLM-Calls tracen, Sampling-Strategien für KI-Workloads, datenschutzkonforme Spans und die Baggage API.

19. Januar 202616 Min. LesezeitOronts Engineering Team

Warum OpenTelemetry gewonnen hat

Vor drei Jahren war die Observability-Landschaft fragmentiert. Jaeger für Tracing, Prometheus für Metriken, Fluentd für Logs, jedes mit eigenem SDK, eigenem Protokoll, eigenem Vendor Lock-in. OpenTelemetry hat sie in einen einzigen Standard vereint: ein SDK, ein Protokoll (OTLP), ein Collector, der an jedes Backend routed.

Wir haben OpenTelemetry in unseren Produktionssystemen eingeführt. Dieser Artikel behandelt die Patterns, die in der Produktion wirklich zählen, kein Setup-Tutorial. Für die übergeordnete Observability-Strategie (wann Alerts auslösen, was loggen, wie Metriken strukturieren) siehe unseren KI-Observability-Guide. Dieser Artikel geht tiefer in OpenTelemetry-spezifische Implementierung.

Context Propagation: Der schwierigste Teil

Ein einzelner User-Request kann 5 Services, 3 Message Queues, 2 Worker-Prozesse und einen LLM-Aufruf durchlaufen. Context Propagation stellt sicher, dass der Trace den Request über alle Stationen hinweg verfolgt.

HTTP Propagation (Einfach)

OpenTelemetry instrumentiert HTTP-Clients und -Server automatisch. Der Trace Context wird über traceparent- und tracestate-Header propagiert. Das funktioniert direkt out of the box.

// Auto-instrumentiert: kein Code nötig für HTTP Propagation
// Das SDK fügt den traceparent Header zu ausgehenden Requests hinzu
// Der empfangende Service extrahiert ihn und setzt den Trace fort

Queue Propagation (Schwierig)

Message Queues unterbrechen die automatische Propagation. Wenn du eine Nachricht einreihst, muss der Trace Context in die Message-Header serialisiert werden. Wenn ein Worker sie dequeued, muss der Context extrahiert und der Trace fortgesetzt werden.

// Producer: Trace Context in Message-Header injizieren
import { context, propagation } from '@opentelemetry/api';

async function enqueueMessage(queue: string, payload: any) {
    const carrier: Record<string, string> = {};
    propagation.inject(context.active(), carrier);

    await messageQueue.send(queue, {
        body: payload,
        headers: carrier, // Enthält traceparent, tracestate
    });
}

// Consumer: Trace Context aus Message-Header extrahieren
async function processMessage(message: QueueMessage) {
    const parentContext = propagation.extract(context.active(), message.headers);

    await context.with(parentContext, async () => {
        const span = tracer.startSpan('process_message', {
            attributes: {
                'messaging.system': 'rabbitmq',
                'messaging.operation': 'process',
                'messaging.destination': message.queue,
            },
        });

        try {
            await handleMessage(message.body);
            span.setStatus({ code: SpanStatusCode.OK });
        } catch (error) {
            span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
            throw error;
        } finally {
            span.end();
        }
    });
}

Dieses Pattern funktioniert für RabbitMQ, Kafka, BullMQ, SQS und Symfony Messenger. Die Message-Header tragen den Trace Context. Der Consumer extrahiert ihn und erstellt Child Spans unter dem originalen Trace.

Worker-Prozess-Propagation

Für Vendures BullMQ Worker, Pimcores Symfony Messenger Worker und ähnliche Background-Job-Systeme ist das Pattern identisch: Context in den Job-Payload serialisieren, auf der Worker-Seite extrahieren.

// BullMQ: Trace Context zu Job-Daten hinzufügen
async function addJob(queue: Queue, data: any) {
    const carrier: Record<string, string> = {};
    propagation.inject(context.active(), carrier);

    await queue.add('process', {
        ...data,
        _traceContext: carrier,
    });
}

// BullMQ: Trace Context im Worker extrahieren
worker.on('process', async (job) => {
    const parentContext = propagation.extract(context.active(), job.data._traceContext || {});

    await context.with(parentContext, async () => {
        const span = tracer.startSpan(`job:${job.name}`);
        try {
            await processJob(job.data);
        } finally {
            span.end();
        }
    });
});

LLM-Calls tracen

LLM-Aufrufe sind die teuersten Operationen in KI-Systemen. Sie mit den richtigen Attributen zu tracen ermöglicht Cost Tracking, Latenz-Analyse und Quality Monitoring.

async function tracedLlmCall(prompt: string, options: LlmOptions): Promise<string> {
    const span = tracer.startSpan('llm.generate', {
        attributes: {
            'llm.provider': options.provider,           // "openai", "anthropic"
            'llm.model': options.model,                 // "gpt-4o", "claude-sonnet-4-20250514"
            'llm.temperature': options.temperature,
            'llm.max_tokens': options.maxTokens,
            'llm.prompt_tokens': estimateTokens(prompt), // Schätzung vor dem Aufruf
        },
    });

    try {
        const response = await llmClient.generate(prompt, options);

        span.setAttributes({
            'llm.response_tokens': response.usage.completionTokens,
            'llm.total_tokens': response.usage.totalTokens,
            'llm.finish_reason': response.finishReason,
            'llm.cost_usd': calculateCost(response.usage, options.model),
        });

        span.setStatus({ code: SpanStatusCode.OK });
        return response.text;
    } catch (error) {
        span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
        span.setAttribute('llm.error_type', error.constructor.name);
        throw error;
    } finally {
        span.end();
    }
}

Schreibe KEINE Prompt-Texte in Span-Attribute. Prompts enthalten personenbezogene Daten (PII). Span-Attribute werden an dein Observability-Backend gesendet (Jaeger, Grafana Tempo, Datadog). Logge stattdessen den Prompt-Hash oder den Token Count. Für die vollständige PII-sichere Logging-Architektur siehe unseren KI-Datenleck-Prävention-Guide.

LLM Span Attributes Convention

AttributTypBeispiel
llm.providerstring"openai"
llm.modelstring"gpt-4o"
llm.temperaturefloat0.7
llm.prompt_tokensint1250
llm.response_tokensint340
llm.total_tokensint1590
llm.finish_reasonstring"stop"
llm.cost_usdfloat0.023
llm.error_typestring"RateLimitError"
llm.cache_hitbooleanfalse

Sampling-Strategien

In hochvolumigen KI-Systemen ist das Tracen jedes Requests zu teuer. Sampling reduziert das Volumen und bewahrt gleichzeitig die Sichtbarkeit auf wichtige Traces.

Head-Based Sampling

Entscheide am Anfang des Trace, ob er gesampled wird. Einfach, aber verlustbehaftet.

// 10% aller Traces samplen
const sampler = new TraceIdRatioBased(0.1);

// Fehler immer samplen (Override der Rate für Error Traces)
const compositeSampler = new ParentBasedSampler({
    root: new TraceIdRatioBased(0.1),
    // Errors werden immer via Span Processor gesampled
});

Tail-Based Sampling (Empfohlen für KI)

Entscheide nach Abschluss des Trace, ob er behalten wird. Behält alle interessanten Traces (Fehler, langsame Antworten, hohe Kosten) und verwirft Routine-Traces.

// OpenTelemetry Collector: Tail-Based Sampling Konfiguration
processors:
    tail_sampling:
        decision_wait: 10s
        policies:
            # Alle Fehler behalten
            - name: errors
              type: status_code
              status_code: { status_codes: [ERROR] }

            # Langsame Traces behalten (> 5 Sekunden)
            - name: slow
              type: latency
              latency: { threshold_ms: 5000 }

            # Teure LLM-Aufrufe behalten (> $0.10)
            - name: expensive_llm
              type: string_attribute
              string_attribute:
                  key: llm.cost_usd
                  values: []  # Custom: Filterung in der Pipeline
                  enabled_regex_matching: true

            # 5% von allem anderen samplen
            - name: baseline
              type: probabilistic
              probabilistic: { sampling_percentage: 5 }

Tail-Based Sampling erfordert den OpenTelemetry Collector. Der Collector puffert vollständige Traces, evaluiert Policies und leitet nur gesamplete Traces ans Backend weiter. Das fügt Latenz hinzu (die decision_wait-Periode), reduziert aber die Speicherkosten dramatisch, während alle interessanten Daten erhalten bleiben.

Datenschutzkonforme Spans

Span-Attribute, Span-Namen und Span-Events werden alle an dein Observability-Backend gesendet. Wenn eines davon personenbezogene Daten enthält, wird deine Tracing-Infrastruktur zu einer Datenschutz-Haftung.

// SCHLECHT: PII in Span-Attributen
span.setAttribute('user.email', 'sara.mustermann@beispiel.de');
span.setAttribute('user.name', 'Sara Mustermann');
span.setAttribute('request.body', JSON.stringify(requestBody)); // Enthält PII

// GUT: Nur Token-IDs und aggregierte Daten
span.setAttribute('user.id', 'usr_abc123');  // Opaque ID, kein PII
span.setAttribute('entities.detected', 3);
span.setAttribute('entities.types', ['person', 'email', 'phone']);
span.setAttribute('policy.applied', 'german-support');

Regeln für datenschutzkonformes Tracing:

  • User-IDs: nur opaque Identifier (keine E-Mails, keine Namen)
  • Request Bodies: niemals Rohdaten einschließen. Logge Entity Counts und Typen.
  • LLM-Prompts: niemals einschließen. Logge Token Counts und Prompt-Hash.
  • Fehlermeldungen: sanitize vor dem Anhängen an Spans. Entferne alle Benutzerdaten.

Die Baggage API

OpenTelemetry Baggage transportiert Key-Value-Paare über Service-Grenzen hinweg. Anders als Span-Attribute (die beim Span bleiben) propagiert Baggage automatisch an alle Downstream-Services.

import { propagation, context, baggage } from '@opentelemetry/api';

// Baggage am API Gateway setzen
const bag = propagation.createBaggage({
    'tenant.id': { value: 'tenant_acme' },
    'request.priority': { value: 'high' },
    'feature.flags': { value: 'new-checkout,beta-search' },
});
const ctx = propagation.setBaggage(context.active(), bag);

// Downstream-Services können Baggage lesen
const tenantId = propagation.getBaggage(context.active())?.getEntry('tenant.id')?.value;

Nützlich für:

  • Tenant-ID-Propagation (jeder Downstream-Service kennt den Tenant)
  • Feature Flags (Experiment-Zuweisungen über Services hinweg propagieren)
  • Priority Routing (hochpriorisierte Requests bekommen andere Queue-Behandlung)
  • Debug Marker (bestimmte Requests für ausführliches Logging markieren)

Baggage reist mit dem Trace Context in HTTP-Headern und Message-Metadaten. Jeder Service, der den Trace Context extrahiert, bekommt auch die Baggage.

Collector-Architektur

Der OpenTelemetry Collector ist die zentrale Routing-Schicht zwischen deinen Anwendungen und deinen Observability-Backends.

┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│  Service A   │  │  Service B   │  │  Worker C    │
│  (OTLP gRPC) │  │  (OTLP HTTP) │  │  (OTLP gRPC) │
└──────┬───────┘  └──────┬───────┘  └──────┬───────┘
       │                 │                 │
       ▼                 ▼                 ▼
┌─────────────────────────────────────────────────┐
│              OTel Collector                      │
│                                                  │
│  Receivers: OTLP (gRPC + HTTP)                  │
│  Processors: batch, tail_sampling, attributes    │
│  Exporters: Tempo, Prometheus, Loki             │
└─────────────────────────────────────────────────┘
       │                 │                 │
       ▼                 ▼                 ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│  Grafana     │ │  Prometheus  │ │  Grafana     │
│  Tempo       │ │              │ │  Loki        │
│  (Traces)    │ │  (Metriken)  │ │  (Logs)      │
└──────────────┘ └──────────────┘ └──────────────┘

Der Collector handhabt Batching (reduziert Netzwerkaufrufe), Sampling (reduziert Speicher), Attribut-Verarbeitung (fügt Attribute hinzu/entfernt sie) und Routing (verschiedene Signale an verschiedene Backends). Deploye ihn als Sidecar oder als zentralen Service, abhängig von deiner Infrastruktur.

Für Cloud-Deployment-Patterns einschließlich Observability-Infrastruktur beschreibt diese Seite unseren Ansatz.

Häufige Stolperfallen

  1. Keine Context Propagation über Queues. HTTP Propagation ist automatisch. Queue Propagation nicht. Wenn du Context nicht in Message-Header injizierst/extrahierst, brechen Traces an jeder Queue-Grenze.

  2. PII in Span-Attributen. Dein Tracing-Backend indiziert alles. Wenn Spans E-Mails, Namen oder Request Bodies enthalten, ist dein Grafana Tempo Cluster ein PII-Speicher.

  3. Jeden Request in der Produktion tracen. Bei 1000 RPS generiert volles Tracing Terabytes an Daten. Verwende Tail-Based Sampling, um Fehler, langsame Traces und teure Operationen zu behalten.

  4. Keine LLM-spezifischen Attribute. Ohne Token Counts, Kosten, Model-ID und Finish Reason auf LLM-Spans kannst du KI-Kosten nicht tracken oder Qualitätsprobleme diagnostizieren.

  5. Head-Based Sampling verwirft Fehler. Wenn du 10% der Traces samplest und ein Fehler in den 90% passiert, die du verwirfst, siehst du ihn nie. Verwende Tail-Based Sampling oder Always-Sample-Errors-Policies.

  6. Baggage für große Payloads. Baggage reist mit jedem Request. Große Werte erhöhen die Header-Größe bei jedem HTTP-Aufruf. Halte Baggage-Werte klein (IDs, Flags, Prioritäten).

Kernerkenntnisse

  • Context Propagation über Queues ist der schwierigste Teil. HTTP ist automatisch. Queues erfordern manuelles Inject/Extract des Trace Context in Message-Headern. Hier scheitern die meisten Distributed-Tracing-Implementierungen.

  • Trace LLM-Aufrufe mit Kosten- und Token-Attributen. Model, Provider, Token Counts, Kosten, Finish Reason. Diese Attribute ermöglichen KI-Kosten-Dashboards und Quality Monitoring.

  • Tail-Based Sampling für KI-Workloads. Behalte alle Fehler, langsamen Traces und teuren Operationen. Verwirf Routine-Traces. Reduziert den Speicher um 90%+ und behält alle interessanten Daten.

  • Kein PII in Spans. Opaque User-IDs, Entity Counts, Token Types. Niemals Rohdaten, E-Mails, Namen oder Request Bodies.

  • Baggage propagiert Tenant Context. Setze Tenant-ID, Feature Flags und Priorität am Edge. Jeder Downstream-Service liest es aus der Baggage, ohne explizite Parameterübergabe.

Wir implementieren OpenTelemetry in unseren KI-Services, Custom-Software-Projekten und unserer Cloud-Infrastruktur. Wenn du Observability für ein verteiltes System aufbaust, sprich mit unserem Team oder fordere ein Angebot an.

Behandelte Themen

OpenTelemetry ProduktionDistributed TracingTrace Context PropagationObservability-ArchitekturOTel PatternsOpenTelemetry LLMOpenTelemetry Queues

Bereit, produktionsreife KI-Systeme zu bauen?

Unser Team ist spezialisiert auf produktionsreife KI-Systeme. Lass uns besprechen, wie wir deinem Unternehmen helfen können.

Gespräch starten