Zum Inhalt springen

Distributed Tracing: Requests über Services hinweg verfolgen

Distributed Tracing in Microservices mit OpenTelemetry: Kontextpropagation, Spans, Sampling und die Analyse, die Systeme debuggbar macht.

5 Min. Lesezeit
Visualisierung einer verteilten Trace, die einen Request durch mehrere Microservices mit Zeit-Spans und Fehlerindikatoren fließen zeigt

Wenn ein Benutzer auf "Checkout" klickt und es 8 Sekunden statt 1 dauert, bedeutet die Suche nach dem Flaschenhals in einem Monolithen, einen einzigen Prozess anzuschauen. In einem verteilten System kann diese Anfrage ein API-Gateway, einen Authentifizierungs-Service, einen Inventory-Service, einen Payment-Processor, einen Notification-Service und ein Dutzend Middleware-Komponenten dazwischen durchlaufen. Ohne Distributed Tracing rätst du, welcher Service langsam ist. Damit siehst du genau, wo die zusätzlichen 7 Sekunden verbraucht wurden.

Distributed Tracing gibt jeder Anfrage eine eindeutige Identität, die sie über jede Service-Grenze hinweg verfolgt, und zeichnet Zeit und Metadaten jeder Operation auf. Es verwandelt das undurchsichtige "irgendetwas ist langsam" in "der Inventory-Service hat 6.5 Sekunden auf eine Datenbankabfrage gewartet, die normalerweise 50ms dauert."

Trace, Span und Context: Kernkonzepte

Ein Trace repräsentiert die komplette Reise einer Anfrage. Er besteht aus Spans — einzelnen Arbeitseinheiten. Spans bilden einen Baum, mit Eltern-Kind-Beziehungen, die zeigen, wie Operationen verschachtelt sind und voneinander abhängen.

tstypescript
import {
  trace,
  context,
  SpanKind,
  SpanStatusCode,
} from '@opentelemetry/api';
 
// A trace is automatically created when you start the first span
const tracer = trace.getTracer('checkout-service', '1.0.0');
 
async function processCheckout(orderId: string): Promise<Order> {
  // This creates the root span for the checkout trace
  return tracer.startActiveSpan(
    'checkout.process',
    { kind: SpanKind.SERVER },
    async (span) => {
      try {
        span.setAttribute('order.id', orderId);
 
        // Each child span appears nested under the parent
        const inventory = await checkInventory(orderId);
        const payment = await processPayment(orderId);
        const confirmation = await sendConfirmation(orderId);
 
        span.setStatus({ code: SpanStatusCode.OK });
        return confirmation;
      } catch (error) {
        span.setStatus({
          code: SpanStatusCode.ERROR,
          message: error instanceof Error ? error.message : 'Unknown error',
        });
        span.recordException(error as Error);
        throw error;
      } finally {
        span.end();
      }
    }
  );
}
 
// Child span — automatically linked to parent via context
async function checkInventory(orderId: string): Promise<InventoryResult> {
  return tracer.startActiveSpan(
    'inventory.check',
    { kind: SpanKind.CLIENT },
    async (span) => {
      try {
        span.setAttribute('order.id', orderId);
        const result = await fetch(
          `http://inventory-service/check/${orderId}`
        );
        span.setAttribute('inventory.available', true);
        return result.json();
      } finally {
        span.end();
      }
    }
  );
}

Context-Propagation über Service-Grenzen hinweg

Das kritische Stück, das Distributed Tracing funktionieren lässt: wenn Service A Service B aufruft, muss der Trace-Context mit der Anfrage mitreisen. OpenTelemetry erledigt das mit Propagatoren, die Context in HTTP-Header injizieren.

tstypescript
// ❌ Without context propagation: separate disconnected traces
// Service A creates Trace-A, Service B creates Trace-B
// No way to correlate them
 
// ✅ With context propagation: one connected trace
// Service A's context flows to Service B via HTTP headers
 
// --- Setup (once at application startup) ---
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from
  '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from
  '@opentelemetry/exporter-trace-otlp-http';
 
const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: 'http://otel-collector:4318/v1/traces',
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      // Auto-instruments HTTP, Express, database clients
      // Context propagation happens automatically
      '@opentelemetry/instrumentation-http': {
        enabled: true,
      },
      '@opentelemetry/instrumentation-express': {
        enabled: true,
      },
    }),
  ],
});
 
sdk.start();
 
// When Service A makes an HTTP request, the instrumentation
// automatically adds these headers:
// traceparent: 00-<trace-id>-<span-id>-01
// tracestate: <vendor-specific-data>
 
// Service B's instrumentation reads these headers and
// continues the same trace, creating child spans under
// the calling span from Service A
tstypescript
// Manual context propagation for non-HTTP transports
import {
  propagation,
  context as otelContext,
  ROOT_CONTEXT,
} from '@opentelemetry/api';
 
// Injecting context into a message queue message
function publishEvent(
  queue: MessageQueue,
  event: DomainEvent
): void {
  const carrier: Record<string, string> = {};
 
  // Inject current trace context into carrier object
  propagation.inject(otelContext.active(), carrier);
 
  queue.publish({
    ...event,
    headers: carrier, // Contains traceparent, tracestate
  });
}
 
// Extracting context when consuming from the queue
function consumeEvent(message: QueueMessage): void {
  const parentContext = propagation.extract(
    ROOT_CONTEXT,
    message.headers
  );
 
  // Create spans within the extracted context
  otelContext.with(parentContext, () => {
    tracer.startActiveSpan('queue.process', (span) => {
      processMessage(message);
      span.end();
    });
  });
}

Bedeutungsvolle Span-Attribute

Traces werden zu mächtigen Debugging-Werkzeugen, wenn Spans die richtigen Attribute mitführen. Zeichne Geschäftskontext auf, nicht nur technische Metadaten.

tstypescript
async function handlePayment(
  order: Order,
  paymentMethod: PaymentMethod
): Promise<PaymentResult> {
  return tracer.startActiveSpan('payment.process', async (span) => {
    // Business context for debugging and analysis
    span.setAttribute('payment.method', paymentMethod.type);
    span.setAttribute('payment.currency', order.currency);
    span.setAttribute('order.item_count', order.items.length);
    span.setAttribute('order.total_cents', order.totalCents);
    span.setAttribute('customer.tier', order.customer.tier);
 
    try {
      const result = await paymentGateway.charge(order);
 
      span.setAttribute('payment.gateway', result.gateway);
      span.setAttribute('payment.status', result.status);
      span.setAttribute('payment.transaction_id', result.transactionId);
 
      // Events mark significant moments within a span
      span.addEvent('payment.authorized', {
        'payment.authorization_code': result.authCode,
      });
 
      return result;
    } catch (error) {
      span.addEvent('payment.failed', {
        'payment.error_code': (error as PaymentError).code,
        'payment.retry_eligible': (error as PaymentError).retryable,
      });
      throw error;
    } finally {
      span.end();
    }
  });
}

Sampling-Strategien für Produktion

Jede Anfrage in der Produktion zu tracen erzeugt enorme Volumen. Sampling hält die Kosten beherrschbar, während du dennoch Probleme erkennst.

tstypescript
import {
  ParentBasedSampler,
  TraceIdRatioBasedSampler,
  AlwaysOnSampler,
} from '@opentelemetry/sdk-trace-base';
 
// ❌ Trace everything — expensive, usually unnecessary
const alwaysOn = new AlwaysOnSampler();
 
// ✅ Head-based sampling: decide at trace start
// Sample 10% of traces randomly
const ratioSampler = new TraceIdRatioBasedSampler(0.1);
 
// ✅ Parent-based: respect the parent's sampling decision
// If a parent trace was sampled, continue sampling
const parentBased = new ParentBasedSampler({
  root: new TraceIdRatioBasedSampler(0.1),
});
 
// ✅ Custom sampler: always trace errors and slow requests
class SmartSampler {
  shouldSample(context: any, traceId: string, attributes: any) {
    // Always sample errors
    if (attributes?.['http.status_code'] >= 500) {
      return { decision: 1 }; // RECORD_AND_SAMPLED
    }
 
    // Always sample specific endpoints
    const path = attributes?.['http.target'] || '';
    if (path.startsWith('/api/checkout')) {
      return { decision: 1 };
    }
 
    // 5% sampling for everything else
    const hash = this.hashTraceId(traceId);
    return {
      decision: hash < 0.05 ? 1 : 0,
    };
  }
 
  private hashTraceId(traceId: string): number {
    let hash = 0;
    for (let i = 0; i < traceId.length; i++) {
      hash = ((hash << 5) - hash + traceId.charCodeAt(i)) | 0;
    }
    return Math.abs(hash) / 2147483647;
  }
}

Traces analysieren: Den Flaschenhals finden

Traces zu sammeln ist nur wertvoll, wenn du sie effektiv abfragen und analysieren kannst. Strukturiere deine Analyse um die Fragen herum, die du tatsächlich während Vorfällen stellst.

tstypescript
// Common trace analysis patterns
 
// 1. Find the slowest span in a trace
function findBottleneck(spans: Span[]): Span {
  return spans.reduce((slowest, span) => {
    const duration = span.endTime - span.startTime;
    const slowestDuration = slowest.endTime - slowest.startTime;
    return duration > slowestDuration ? span : slowest;
  });
}
 
// 2. Calculate time spent in each service
function serviceBreakdown(
  spans: Span[]
): Map<string, number> {
  const breakdown = new Map<string, number>();
 
  for (const span of spans) {
    const service = span.attributes['service.name'] as string;
    const duration = span.endTime - span.startTime;
    breakdown.set(
      service,
      (breakdown.get(service) || 0) + duration
    );
  }
 
  return breakdown;
}
 
// 3. Alert on traces exceeding SLA thresholds
// In your trace collector pipeline:
// SELECT
//   trace_id,
//   duration_ms,
//   root_span_name,
//   service_name
// FROM traces
// WHERE duration_ms > 3000       -- SLA threshold
//   AND root_span_name = 'checkout.process'
//   AND timestamp > NOW() - INTERVAL '5 minutes'
// ORDER BY duration_ms DESC

Wichtige Erkenntnisse

Distributed Tracing gibt jeder Anfrage eine eindeutige Identität, die sie über jede Service-Grenze hinweg verfolgt—und verwandelt "irgendetwas ist langsam" in "der Inventory-Service hat 6.5 Sekunden in einer Datenbankabfrage verbracht"—aber nur, wenn du den Trace-Context durch jeden Transportmechanismus deiner Services propagierst, einschließlich HTTP-Headern, Message-Queue-Metadaten und gRPC-Interceptoren. Zeichne Geschäftsattribute auf Spans auf (Bestellbetrag, Kunden-Tier, Zahlungsmethode) zusammen mit technischen, denn während einer Vorfallsuntersuchung willst du wissen "sind Premium-Kunden betroffen?" und "ist das spezifisch für Kreditkartenzahlungen?", nicht nur welche HTTP-Endpoints aufgerufen wurden. Sample strategisch in der Produktion—trace immer Fehler, trace immer kritische Geschäftsflüsse wie Checkout und sample 5-10% des restlichen Traffics zufällig—denn alles zu tracen ist prohibitiv teuer, aber nichts zu tracen bedeutet, dass du während Vorfällen blind bist. Auto-Instrumentierung durch OpenTelemetry-SDKs erledigt 80% der Arbeit, indem sie automatisch Spans für HTTP-Aufrufe, Datenbankabfragen und Framework-Operationen erstellt, also fang dort an und füge nur manuelle Spans für geschäftsspezifische Logik hinzu.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX