Zum Inhalt springen

OpenTelemetry-Instrumentierung für Node.js

Praxisnahe Anleitung zur Instrumentierung von Node.js mit OpenTelemetry: Traces, Metriken, Logs, Auto-Instrumentierung, Spans und Exporter.

4 Min. Lesezeit
Wasserfallansicht eines OpenTelemetry-Trace mit Spans über mehrere Microservices hinweg, inklusive Zeitangaben und Metadaten

OpenTelemetry ist der offene Standard zum Erfassen von Traces, Metriken und Logs aus Anwendungen. Er ersetzt herstellerspezifische SDKs durch eine einzige Instrumentierungsschicht, die in jedes Backend exportieren kann — Jaeger, Grafana, Datadog oder New Relic. Statt die Instrumentierung bei jedem Wechsel des Observability-Anbieters herauszureißen, änderst du einfach die Konfiguration des Exporters.

Für Node.js bietet OpenTelemetry eine Auto-Instrumentierung, die HTTP-Requests, Datenbankabfragen und Framework-Operationen ohne Codeänderungen erfasst. Benutzerdefinierte Spans fügen anwendungsspezifischen Kontext hinzu: welcher Nutzer den Request ausgelöst hat, welcher Feature-Flag-Pfad durchlaufen wurde, wie lange die Geschäftslogik getrennt von I/O gebraucht hat.

Auto-Instrumentierung einrichten

Die Auto-Instrumentierung patcht gängige Bibliotheken (Express, Fastify, pg, Redis, HTTP), um automatisch Spans zu erzeugen. Du initialisierst sie, bevor du den Code deiner Anwendung importierst.

tstypescript
// tracing.ts — load this BEFORE your application
import { NodeSDK } from "@opentelemetry/sdk-node";
import {
  getNodeAutoInstrumentations,
} from "@opentelemetry/auto-instrumentations-node";
import {
  OTLPTraceExporter,
} from "@opentelemetry/exporter-trace-otlp-http";
import {
  OTLPMetricExporter,
} from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { Resource } from "@opentelemetry/resources";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
 
const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: "payment-service",
    [ATTR_SERVICE_VERSION]: "2.4.1",
    environment: process.env.NODE_ENV ?? "development",
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + "/v1/traces",
  }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + "/v1/metrics",
    }),
    exportIntervalMillis: 15_000,
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-http": {
        ignoreIncomingPaths: ["/health", "/ready"],
      },
    }),
  ],
});
 
sdk.start();
 
process.on("SIGTERM", async () => {
  await sdk.shutdown();
  process.exit(0);
});
jsonjson
// package.json — start with tracing loaded first
{
  "scripts": {
    "start": "node --require ./dist/tracing.js ./dist/index.js",
    "dev": "tsx --require ./src/tracing.ts ./src/index.ts"
  }
}

Benutzerdefinierte Spans hinzufügen

Die Auto-Instrumentierung erfasst Infrastrukturoperationen, kann aber keine Geschäftslogik abbilden. Benutzerdefinierte Spans fügen Kontext hinzu, etwa "Zahlung für Nutzer X wird verarbeitet" oder "Rabattregeln werden angewendet".

tstypescript
import { trace, SpanStatusCode, context } from "@opentelemetry/api";
 
const tracer = trace.getTracer("payment-service", "2.4.1");
 
interface PaymentRequest {
  userId: string;
  amount: number;
  currency: string;
  method: "card" | "bank_transfer" | "wallet";
}
 
async function processPayment(request: PaymentRequest): Promise<string> {
  return tracer.startActiveSpan("processPayment", async (span) => {
    try {
      // Add attributes for filtering and grouping in your backend
      span.setAttributes({
        "payment.user_id": request.userId,
        "payment.amount": request.amount,
        "payment.currency": request.currency,
        "payment.method": request.method,
      });
 
      // Nested span for validation
      const validated = await tracer.startActiveSpan(
        "validatePayment",
        async (validationSpan) => {
          const result = await validatePaymentDetails(request);
          validationSpan.setAttribute("payment.valid", result.valid);
          validationSpan.end();
          return result;
        }
      );
 
      if (!validated.valid) {
        span.setStatus({
          code: SpanStatusCode.ERROR,
          message: validated.reason,
        });
        throw new Error(validated.reason);
      }
 
      // Nested span for provider call
      const chargeId = await tracer.startActiveSpan(
        "chargeProvider",
        async (providerSpan) => {
          providerSpan.setAttribute("payment.provider", "stripe");
          const id = await chargePaymentProvider(request);
          providerSpan.setAttribute("payment.charge_id", id);
          providerSpan.end();
          return id;
        }
      );
 
      span.setStatus({ code: SpanStatusCode.OK });
      return chargeId;
    } catch (error) {
      span.recordException(error as Error);
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: (error as Error).message,
      });
      throw error;
    } finally {
      span.end();
    }
  });
}

Benutzerdefinierte Metriken

Traces zeigen einzelne Requests. Metriken zeigen aggregiertes Verhalten — Request-Raten, Fehlerraten, Latenzen und Geschäftskennzahlen wie den Umsatz pro Minute.

tstypescript
import { metrics } from "@opentelemetry/api";
 
const meter = metrics.getMeter("payment-service", "2.4.1");
 
// Counter: monotonically increasing value
const paymentCounter = meter.createCounter("payments.total", {
  description: "Total number of payment attempts",
  unit: "1",
});
 
// Histogram: distribution of values (latencies, sizes)
const paymentDuration = meter.createHistogram("payments.duration", {
  description: "Payment processing duration",
  unit: "ms",
});
 
// Up/Down Counter: value that increases and decreases
const activePayments = meter.createUpDownCounter(
  "payments.active",
  {
    description: "Currently processing payments",
    unit: "1",
  }
);
 
// Usage in request handler
async function handlePayment(request: PaymentRequest): Promise<void> {
  const startTime = Date.now();
  activePayments.add(1, { method: request.method });
 
  try {
    await processPayment(request);
    paymentCounter.add(1, {
      method: request.method,
      status: "success",
    });
  } catch (error) {
    paymentCounter.add(1, {
      method: request.method,
      status: "failure",
      error_type: (error as Error).name,
    });
  } finally {
    const duration = Date.now() - startTime;
    paymentDuration.record(duration, { method: request.method });
    activePayments.add(-1, { method: request.method });
  }
}
tstypescript
// ❌ High-cardinality attributes cause metric explosion
paymentCounter.add(1, {
  user_id: request.userId,      // Millions of unique values
  request_id: request.id,       // Unique per request
  timestamp: Date.now().toString(), // Unique per call
});
// Result: millions of time series, backend crashes or bill explodes
 
// ✅ Low-cardinality attributes for metrics
paymentCounter.add(1, {
  method: request.method,       // 3-4 values: card, bank, wallet
  status: "success",            // 2 values: success, failure
  currency: request.currency,   // ~10 values: USD, EUR, GBP...
});
// Result: ~80 time series (4 × 2 × 10), manageable and useful

Kontextpropagierung zwischen Services

Wenn Service A Service B aufruft, muss der Trace-Kontext weitergegeben werden, damit die Spans beider Services im selben Trace erscheinen. OpenTelemetry übernimmt das bei HTTP-Aufrufen automatisch über W3C-Trace-Context-Header.

tstypescript
// Service A: makes an outgoing HTTP call
// OpenTelemetry auto-instrumentation automatically injects
// traceparent and tracestate headers:
//
// traceparent: 00-<trace-id>-<span-id>-01
// tracestate: <vendor-specific data>
//
// You don't need to do anything — the HTTP instrumentation handles it.
 
import express from "express";
 
const app = express();
 
app.post("/api/orders", async (req, res) => {
  // This span is automatically created by Express instrumentation
  // The HTTP call below automatically propagates trace context
  const paymentResult = await fetch(
    "http://payment-service:3001/api/charge",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ amount: req.body.total }),
    }
  );
 
  // Both the Express span and the fetch span (and the
  // payment-service spans) appear in the same trace
  res.json({ orderId: "ord_123", paymentStatus: "charged" });
});
ymlyaml
# Docker Compose for local development with OpenTelemetry
version: "3.8"
 
services:
  payment-service:
    build: ./payment-service
    environment:
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
      OTEL_SERVICE_NAME: payment-service
 
  order-service:
    build: ./order-service
    environment:
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
      OTEL_SERVICE_NAME: order-service
 
  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    volumes:
      - ./otel-config.yaml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4317:4317"   # gRPC receiver
      - "4318:4318"   # HTTP receiver
 
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686" # Jaeger UI

Die wichtigsten Erkenntnisse

  1. Initialisiere OpenTelemetry, bevor du den Anwendungscode importierst — die Auto-Instrumentierung patcht Bibliotheken zum Importzeitpunkt; lädst du sie erst nach deiner App, gehen die Instrumentierungs-Hooks verloren.
  2. Die Auto-Instrumentierung deckt die Infrastruktur ab, benutzerdefinierte Spans die Geschäftslogik — HTTP-Requests und Datenbankabfragen werden automatisch erfasst; ergänze benutzerdefinierte Spans für domänenspezifische Vorgänge.
  3. Verwende Attribute mit niedriger Kardinalität für Metriken — Nutzer-IDs, Request-IDs und Zeitstempel erzeugen Millionen von Zeitreihen; beschränke dich auf Dimensionen wie Methode, Status und Kategorie.
  4. Die Kontextpropagierung läuft bei HTTP automatisch ab — W3C-Trace-Context-Header werden von der HTTP-Instrumentierung injiziert und ausgelesen; eine manuelle Header-Verwaltung ist nicht nötig.
  5. Zeichne Exceptions auf und setze bei Fehlern den Span-Status — span.recordException() erfasst den Stacktrace; span.setStatus(ERROR) markiert den Span in Trace-Visualisierungen rot.
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX