Observability in Node.js: Logs, Metriken und Traces ohne die Komplexität
Structured Logging mit Pino, Metriken mit Prometheus, Distributed Tracing mit OpenTelemetry. Der Observability-Stack, den ich für Node.js-Produktions-Apps verwende, von null bis zur vollen Sichtbarkeit.
Ich dachte früher, Observability bedeutet „füge ein paar console.logs hinzu und schau sie dir an, wenn etwas kaputt geht." Das funktionierte, bis es nicht mehr funktionierte. Der Wendepunkt war ein Produktionsvorfall, bei dem unsere API 200er zurückgab, aber die Daten veraltet waren. Keine Fehler in den Logs. Keine Exceptions. Einfach stillschweigend falsche Antworten, weil ein Downstream-Cache veraltet war und es vier Stunden lang niemandem auffiel.
Da lernte ich den Unterschied zwischen Monitoring und Observability. Monitoring sagt dir, dass etwas falsch ist. Observability sagt dir, warum es falsch ist. Und die Lücke zwischen diesen beiden ist der Ort, an dem Produktionsvorfälle leben.
Das ist der Observability-Stack, bei dem ich für Node.js-Services gelandet bin, nachdem ich die meisten Alternativen ausprobiert habe. Es ist nicht das ausgefeilteste Setup der Welt, aber es fängt Probleme ab, bevor Benutzer sie bemerken, und wenn doch etwas durchrutscht, kann ich es in Minuten statt Stunden diagnostizieren.
Die drei Säulen, und warum du alle drei brauchst#
Jeder redet über die „drei Säulen der Observability" — Logs, Metriken und Traces. Was dir niemand sagt, ist, dass jede Säule eine grundlegend andere Frage beantwortet, und du alle drei brauchst, weil keine einzelne Säule jede Frage beantworten kann.
Logs beantworten: Was ist passiert?
Eine Log-Zeile sagt „um 14:23:07 hat Benutzer 4821 /api/orders angefragt und bekam einen 500, weil die Datenbankverbindung getimed out hat." Es ist eine Erzählung. Sie erzählt dir die Geschichte eines bestimmten Ereignisses.
Metriken beantworten: Wie viel passiert?
Eine Metrik sagt „in den letzten 5 Minuten war die p99-Antwortzeit 2,3 Sekunden und die Fehlerrate 4,7%." Es sind aggregierte Daten. Sie sagen dir über den Gesundheitszustand des Systems als Ganzes, nicht über eine einzelne Anfrage.
Traces beantworten: Wo ist die Zeit geblieben?
Ein Trace sagt „diese Anfrage verbrachte 12ms in der Express-Middleware, 3ms beim Parsen des Body, 847ms beim Warten auf PostgreSQL und 2ms beim Serialisieren der Antwort." Es ist ein Wasserfall. Er zeigt dir genau, wo der Engpass ist, über Service-Grenzen hinweg.
Hier ist die praktische Implikation: Wenn dein Pager um 3 Uhr nachts losgeht, ist die Reihenfolge fast immer dieselbe.
- Metriken sagen dir, dass etwas falsch ist (Fehlerraten-Spike, Latenz-Anstieg)
- Logs sagen dir, was passiert (spezifische Fehlermeldungen, betroffene Endpoints)
- Traces sagen dir warum (welcher Downstream-Service oder welche Datenbankabfrage der Engpass ist)
Wenn du nur Logs hast, weißt du was kaputt ist, aber nicht wie schlimm es ist. Wenn du nur Metriken hast, weißt du wie schlimm, aber nicht was es verursacht. Wenn du nur Traces hast, hast du schöne Wasserfälle, aber keine Möglichkeit zu wissen, wann du sie dir ansehen solltest.
Lass uns jede einzelne aufbauen.
Structured Logging mit Pino#
Warum console.log nicht ausreicht#
Ich weiß. Du verwendest console.log in der Produktion und es ist „in Ordnung." Lass mich dir zeigen, warum es das nicht ist.
// Was du schreibst
console.log("User login failed", email, error.message);
// Was in deiner Log-Datei landet
// User login failed john@example.com ECONNREFUSED
// Versuche jetzt:
// 1. Alle Login-Fehler der letzten Stunde zu suchen
// 2. Fehler pro Benutzer zu zählen
// 3. Nur die ECONNREFUSED-Fehler zu filtern
// 4. Dies mit der Anfrage zu korrelieren, die es ausgelöst hat
// Viel Glück. Es ist ein unstrukturierter String. Du grepst durch Text.Structured Logging bedeutet, dass jeder Log-Eintrag ein JSON-Objekt mit konsistenten Feldern ist:
// Wie Structured Logging aussieht
{
"level": 50,
"time": 1709312587000,
"msg": "User login failed",
"email": "john@example.com",
"error": "ECONNREFUSED",
"requestId": "req-abc-123",
"route": "POST /api/auth/login",
"responseTime": 1247,
"pid": 12345
}Jetzt kannst du das abfragen. level >= 50 AND msg = "User login failed" AND time > now() - 1h gibt dir genau das, was du brauchst.
Pino vs Winston#
Ich habe beide ausgiebig verwendet. Hier die Kurzfassung:
Winston ist beliebter, flexibler, hat mehr Transports und ist deutlich langsamer. Es fördert auch schlechte Muster — das „Format"-System macht es zu einfach, unstrukturierte, hübsch formatierte Logs zu erstellen, die in der Entwicklung schön aussehen, aber in der Produktion nicht parsebar sind.
Pino ist schneller (5-10x in Benchmarks), bestimmt über JSON-Output und folgt der Unix-Philosophie: mach eine Sache gut (schreibe JSON nach stdout) und lass andere Tools den Rest erledigen (Transport, Formatierung, Aggregation).
Ich verwende Pino. Der Leistungsunterschied zählt, wenn du tausende Anfragen pro Sekunde loggst, und der opinionierte Ansatz bedeutet, dass jeder Entwickler im Team konsistente Logs produziert.
Grundlegendes Pino-Setup#
// src/lib/logger.ts
import pino from "pino";
const isProduction = process.env.NODE_ENV === "production";
export const logger = pino({
level: process.env.LOG_LEVEL || (isProduction ? "info" : "debug"),
// In der Produktion einfach JSON nach stdout. PM2/Container-Runtime erledigt den Rest.
// In der Entwicklung pino-pretty für menschenlesbare Ausgabe.
...(isProduction
? {}
: {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
},
},
}),
// Standard-Felder auf jeder Log-Zeile
base: {
service: process.env.SERVICE_NAME || "api",
version: process.env.APP_VERSION || "unknown",
},
// Error-Objekte ordentlich serialisieren
serializers: {
err: pino.stdSerializers.err,
error: pino.stdSerializers.err,
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
},
// Sensible Felder redigieren
redact: {
paths: [
"req.headers.authorization",
"req.headers.cookie",
"password",
"creditCard",
"ssn",
],
censor: "[REDACTED]",
},
});Die redact-Option ist kritisch. Ohne sie wirst du irgendwann ein Passwort oder einen API-Key loggen. Es ist keine Frage des Ob, sondern des Wann. Irgendein Entwickler wird logger.info({ body: req.body }, "incoming request") hinzufügen und plötzlich loggst du Kreditkartennummern. Redaction ist dein Sicherheitsnetz.
Log-Level: Benutze sie richtig#
// FATAL (60) - Der Prozess stürzt gleich ab. Weck jemanden auf.
logger.fatal({ err }, "Unrecoverable database connection failure");
// ERROR (50) - Etwas ist fehlgeschlagen, das nicht hätte fehlschlagen sollen. Bald untersuchen.
logger.error({ err, userId, orderId }, "Payment processing failed");
// WARN (40) - Etwas Unerwartetes, aber behandelt. Im Auge behalten.
logger.warn({ retryCount: 3, service: "email" }, "Retry limit approaching");
// INFO (30) - Normale Operationen, die es wert sind, aufgezeichnet zu werden.
logger.info({ userId, action: "login" }, "User authenticated");
// DEBUG (20) - Detaillierte Informationen zum Debuggen. Nie in der Produktion.
logger.debug({ query, params }, "Database query executing");
// TRACE (10) - Extrem detailliert. Nur wenn du verzweifelt bist.
logger.trace({ headers: req.headers }, "Incoming request headers");Die Regel: Wenn du zwischen INFO und DEBUG abwägst, ist es DEBUG. Wenn du zwischen WARN und ERROR abwägst, frag dich: „Möchte ich um 3 Uhr nachts darüber alarmiert werden?" Wenn ja, ERROR. Wenn nein, WARN.
Child-Logger und Request-Kontext#
Hier glänzt Pino wirklich. Ein Child-Logger erbt die gesamte Konfiguration des Eltern-Loggers, fügt aber zusätzliche Kontextfelder hinzu.
// Jeder Log von diesem Child-Logger enthält userId und sessionId
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
userLogger.info("User viewed dashboard");
// Ausgabe enthält userId und sessionId automatisch
userLogger.info({ page: "/settings" }, "User navigated");
// Ausgabe enthält userId, sessionId UND pageFür HTTP-Server willst du einen Child-Logger pro Request, damit jede Log-Zeile im Lebenszyklus dieser Anfrage die Request-ID enthält:
// src/middleware/request-logger.ts
import { randomUUID } from "node:crypto";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
export function requestLogger(req: Request, res: Response, next: NextFunction) {
const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
const startTime = performance.now();
// Einen Child-Logger an den Request anhängen
req.log = logger.child({
requestId,
method: req.method,
url: req.originalUrl,
userAgent: req.headers["user-agent"],
ip: req.headers["x-forwarded-for"]?.toString().split(",").pop()?.trim()
|| req.socket.remoteAddress,
});
res.setHeader("x-request-id", requestId);
req.log.info("Request received");
res.on("finish", () => {
const duration = Math.round(performance.now() - startTime);
const logMethod = res.statusCode >= 500 ? "error"
: res.statusCode >= 400 ? "warn"
: "info";
req.log[logMethod]({
statusCode: res.statusCode,
duration,
contentLength: res.getHeader("content-length"),
}, "Request completed");
});
next();
}AsyncLocalStorage für automatische Kontextpropagation#
Der Child-Logger-Ansatz funktioniert, aber er erfordert, dass du req.log durch jeden Funktionsaufruf durchreichst. Das wird mühsam. AsyncLocalStorage löst das — es bietet einen Kontextspeicher, der dem asynchronen Ausführungsfluss folgt, ohne explizites Durchreichen.
// src/lib/async-context.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { logger } from "./logger";
import type { Logger } from "pino";
interface RequestContext {
requestId: string;
logger: Logger;
userId?: string;
startTime: number;
}
export const asyncContext = new AsyncLocalStorage<RequestContext>();
// Den kontextuellen Logger von überall im Call Stack holen
export function getLogger(): Logger {
const store = asyncContext.getStore();
return store?.logger || logger;
}
export function getRequestId(): string | undefined {
return asyncContext.getStore()?.requestId;
}// src/middleware/async-context-middleware.ts
import { randomUUID } from "node:crypto";
import { asyncContext } from "../lib/async-context";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
export function asyncContextMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
const requestLogger = logger.child({ requestId });
const context = {
requestId,
logger: requestLogger,
startTime: performance.now(),
};
asyncContext.run(context, () => {
res.setHeader("x-request-id", requestId);
next();
});
}Jetzt kann jede Funktion, überall im Call Stack, den request-spezifischen Logger holen:
// src/services/order-service.ts
import { getLogger } from "../lib/async-context";
export async function processOrder(orderId: string) {
const log = getLogger(); // Hat automatisch requestId angehängt!
log.info({ orderId }, "Processing order");
const items = await fetchOrderItems(orderId);
log.debug({ itemCount: items.length }, "Order items fetched");
const total = calculateTotal(items);
log.info({ orderId, total }, "Order processed successfully");
return { orderId, total, items };
}
// Kein Logger als Parameter nötig. Es funktioniert einfach.Log-Aggregation: Wohin gehen Logs?#
In der Entwicklung gehen Logs nach stdout und pino-pretty macht sie lesbar. In der Produktion ist es differenzierter.
Der PM2-Weg#
Wenn du auf einem VPS mit PM2 arbeitest, fängt PM2 stdout automatisch ab:
# Logs in Echtzeit ansehen
pm2 logs api --lines 100
# Logs werden gespeichert unter ~/.pm2/logs/
# api-out.log — stdout (deine JSON-Logs)
# api-error.log — stderr (unbehandelte Exceptions, Stack-Traces)PM2s eingebaute Log-Rotation verhindert Speicherplatzprobleme:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress trueLogs zu Loki oder Elasticsearch schicken#
Für alles jenseits eines einzelnen Servers brauchst du zentralisierte Log-Aggregation. Die zwei Hauptoptionen:
Grafana Loki — Das „Prometheus für Logs." Leichtgewichtig, indiziert nur Labels (keinen Volltext), funktioniert wunderbar mit Grafana. Meine Empfehlung für die meisten Teams.
Elasticsearch — Volltextsuche über Logs. Mächtiger, ressourcenhungriger, mehr operativer Aufwand. Nutze es, wenn du wirklich Volltextsuche über Millionen von Log-Zeilen brauchst.
Für Loki ist das einfachste Setup Promtail zum Versenden von Logs:
# /etc/promtail/config.yml
server:
http_listen_port: 9080
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: node-api
static_configs:
- targets:
- localhost
labels:
job: node-api
environment: production
__path__: /home/deploy/.pm2/logs/api-out.log
pipeline_stages:
- json:
expressions:
level: level
msg: msg
service: service
- labels:
level:
service:
- timestamp:
source: time
format: UnixMsMetriken mit Prometheus#
Logs sagen dir, was passiert ist. Metriken sagen dir, wie das System performt. Der Unterschied ist wie der Unterschied zwischen dem Lesen jeder Transaktion auf deinem Kontoauszug versus dem Betrachten deines Kontostands.
Die vier Metrik-Typen#
Prometheus hat vier Metrik-Typen. Zu verstehen, wann welcher zu verwenden ist, erspart dir die häufigsten Fehler.
Counter — Ein Wert, der nur steigt. Request-Anzahl, Fehleranzahl, verarbeitete Bytes. Setzt sich beim Neustart auf Null zurück.
// "Wie viele Anfragen haben wir bedient?"
const httpRequestsTotal = new Counter({
name: "http_requests_total",
help: "Total number of HTTP requests",
labelNames: ["method", "route", "status_code"],
});Gauge — Ein Wert, der steigen oder fallen kann. Aktuelle Verbindungen, Warteschlangengröße, Heap-Nutzung.
// "Wie viele Verbindungen sind gerade aktiv?"
const activeConnections = new Gauge({
name: "active_connections",
help: "Number of currently active connections",
});Histogram — Beobachtet Werte und zählt sie in konfigurierbaren Buckets. Request-Dauer, Antwortgröße. So bekommst du Percentile (p50, p95, p99).
// "Wie lange dauern Anfragen?" mit Buckets bei 10ms, 50ms, 100ms, usw.
const httpRequestDuration = new Histogram({
name: "http_request_duration_seconds",
help: "Duration of HTTP requests in seconds",
labelNames: ["method", "route", "status_code"],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});Summary — Ähnlich wie Histogram, berechnet aber Quantile auf der Client-Seite. Verwende stattdessen Histogram, es sei denn, du hast einen spezifischen Grund. Summaries können nicht über Instanzen hinweg aggregiert werden.
Vollständiges prom-client-Setup#
// src/lib/metrics.ts
import {
Registry,
Counter,
Histogram,
Gauge,
collectDefaultMetrics,
} from "prom-client";
// Eigene Registry erstellen
export const metricsRegistry = new Registry();
// Standard-Node.js-Metriken sammeln
collectDefaultMetrics({
register: metricsRegistry,
prefix: "nodeapp_",
gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
});
// --- HTTP-Metriken ---
export const httpRequestsTotal = new Counter({
name: "nodeapp_http_requests_total",
help: "Total number of HTTP requests received",
labelNames: ["method", "route", "status_code"] as const,
registers: [metricsRegistry],
});
export const httpRequestDuration = new Histogram({
name: "nodeapp_http_request_duration_seconds",
help: "Duration of HTTP requests in seconds",
labelNames: ["method", "route", "status_code"] as const,
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [metricsRegistry],
});
// --- Business-Metriken ---
export const ordersProcessed = new Counter({
name: "nodeapp_orders_processed_total",
help: "Total number of orders processed",
labelNames: ["status"] as const,
registers: [metricsRegistry],
});
export const externalApiDuration = new Histogram({
name: "nodeapp_external_api_duration_seconds",
help: "Duration of external API calls",
labelNames: ["service", "endpoint", "status"] as const,
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30],
registers: [metricsRegistry],
});
export const dbQueryDuration = new Histogram({
name: "nodeapp_db_query_duration_seconds",
help: "Duration of database queries",
labelNames: ["operation", "table"] as const,
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
registers: [metricsRegistry],
});Die Metriken-Middleware#
// src/middleware/metrics-middleware.ts
import { httpRequestsTotal, httpRequestDuration } from "../lib/metrics";
import type { Request, Response, NextFunction } from "express";
// Routen normalisieren, um Kardinalitätsexplosion zu vermeiden
function normalizeRoute(req: Request): string {
const route = req.route?.path || req.path;
return route
.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "/:uuid")
.replace(/\/\d+/g, "/:id")
.replace(/\/[a-f0-9]{24}/g, "/:objectId");
}
export function metricsMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
if (req.path === "/metrics") {
return next();
}
const end = httpRequestDuration.startTimer();
res.on("finish", () => {
const route = normalizeRoute(req);
const labels = {
method: req.method,
route,
status_code: res.statusCode.toString(),
};
httpRequestsTotal.inc(labels);
end(labels);
});
next();
}Custom Business-Metriken sind die wahre Stärke#
Die Standard-Node.js-Metriken (Heap-Größe, Event-Loop-Lag, GC-Dauer) sind Grundvoraussetzung. Sie sagen dir über die Runtime-Gesundheit. Aber Business-Metriken sagen dir über die Anwendungs-Gesundheit.
// In deinem Order-Service
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
export async function processOrder(order: Order) {
try {
const paymentTimer = externalApiDuration.startTimer({
service: "stripe",
endpoint: "charges.create",
});
const charge = await stripe.charges.create({
amount: order.total,
currency: "usd",
source: order.paymentToken,
});
paymentTimer({ status: "success" });
ordersProcessed.inc({ status: "success" });
return charge;
} catch (err) {
ordersProcessed.inc({ status: "failed" });
throw err;
}
}Ein Spike in ordersProcessed{status="failed"} sagt dir etwas, das keine Menge an CPU-Metriken jemals kann.
Label-Kardinalität: Der stille Killer#
Ein Wort der Warnung. Jede eindeutige Kombination von Label-Werten erstellt eine neue Time Series. Wenn du ein userId-Label zu deinem HTTP-Request-Counter hinzufügst und 100.000 Benutzer hast, hast du gerade 100.000+ Time Series erstellt. Prometheus wird zum Stillstand kommen.
Regeln für Labels:
- Nur niedrige Kardinalität: HTTP-Methode (7 Werte), Statuscode (5 Kategorien), Route (Dutzende, nicht Tausende)
- Verwende niemals User-IDs, Request-IDs, IP-Adressen oder Timestamps als Label-Werte
- Im Zweifelsfall füge das Label nicht hinzu
Grafana-Dashboards#
Prometheus speichert die Daten. Grafana visualisiert sie. Hier sind die Panels, die ich auf jedes Node.js-Service-Dashboard setze.
Das Essential-Dashboard#
1. Request Rate (Anfragen/Sekunde)
rate(nodeapp_http_requests_total[5m])2. Error Rate (%)
100 * (
sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
/
sum(rate(nodeapp_http_requests_total[5m]))
)Die wichtigste einzelne Zahl. Wenn sie über 1% geht, stimmt etwas nicht.
3. p50 / p95 / p99 Latenz
histogram_quantile(0.99,
sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
)4. Event Loop Lag
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}Wenn das über 100ms geht, ist dein Event Loop blockiert.
5. Heap-Nutzung
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100Achte auf einen stetigen Aufwärtstrend — das ist ein Memory Leak.
Distributed Tracing mit OpenTelemetry#
Tracing ist die Säule, die die meisten Teams zuletzt einführen, und die, von der sie sich wünschen, sie hätten sie zuerst eingeführt. Wenn du mehrere Services hast, die miteinander kommunizieren, zeigt Tracing dir das vollständige Bild der Reise einer Anfrage.
Was ist ein Trace?#
Ein Trace ist ein Baum von Spans. Jeder Span repräsentiert eine Arbeitseinheit — eine HTTP-Anfrage, eine Datenbankabfrage, einen Funktionsaufruf. Spans haben eine Startzeit, Endzeit, Status und Attribute. Sie sind durch eine Trace-ID verbunden, die über Service-Grenzen propagiert wird.
Trace: abc-123
├── [API Gateway] POST /api/orders (250ms)
│ ├── [Auth Service] validate-token (12ms)
│ ├── [Order Service] create-order (230ms)
│ │ ├── [PostgreSQL] INSERT INTO orders (15ms)
│ │ ├── [Redis] SET order:cache (2ms)
│ │ └── [Payment Service] charge (200ms)
│ │ ├── [Stripe API] POST /v1/charges (180ms)
│ │ └── [PostgreSQL] UPDATE orders SET status (8ms)
│ └── [Email Service] send-confirmation (async, 45ms)
Ein Blick sagt dir: Die 250ms-Anfrage verbrachte 180ms beim Warten auf Stripe. Dort optimierst du.
OpenTelemetry-Setup#
OpenTelemetry (OTel) ist der Standard. Es ersetzte die fragmentierte Landschaft von Jaeger-Clients, Zipkin-Clients und herstellerspezifischen SDKs durch eine einzige, herstellerneutrale API.
// src/instrumentation.ts
// Diese Datei MUSS vor allen anderen Imports geladen werden.
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
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]: process.env.SERVICE_NAME || "node-api",
[ATTR_SERVICE_VERSION]: process.env.APP_VERSION || "0.0.0",
"deployment.environment": process.env.NODE_ENV || "development",
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
}),
exportIntervalMillis: 15000,
}),
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-fs": { enabled: false },
"@opentelemetry/instrumentation-dns": { enabled: false },
"@opentelemetry/instrumentation-http": {
ignoreIncomingPaths: ["/health", "/ready", "/metrics"],
},
"@opentelemetry/instrumentation-express": {
ignoreLayersType: ["middleware"],
},
}),
],
});
sdk.start();
process.on("SIGTERM", () => {
sdk.shutdown().then(
() => console.log("OTel SDK shut down successfully"),
(err) => console.error("Error shutting down OTel SDK", err)
);
});Starte deine App mit:
node --import ./src/instrumentation.ts ./src/server.tsDas war's. Mit null Änderungen an deinem Anwendungscode hast du jetzt Traces für jede HTTP-Anfrage, jede Datenbankabfrage, jeden Redis-Befehl.
Manuelle Span-Erstellung#
Auto-Instrumentation deckt Infrastruktur-Aufrufe ab, aber manchmal willst du Business-Logik tracen:
// src/services/order-service.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
const tracer = trace.getTracer("order-service");
export async function processOrder(orderId: string): Promise<Order> {
return tracer.startActiveSpan("processOrder", async (span) => {
try {
span.setAttribute("order.id", orderId);
const order = await fetchOrder(orderId);
span.setAttribute("order.total", order.total);
span.setAttribute("order.item_count", order.items.length);
const validationResult = await tracer.startActiveSpan(
"validateOrder",
async (validationSpan) => {
const result = await validateInventory(order);
validationSpan.setAttribute("validation.passed", result.valid);
if (!result.valid) {
validationSpan.setStatus({
code: SpanStatusCode.ERROR,
message: `Validation failed: ${result.reason}`,
});
}
validationSpan.end();
return result;
}
);
if (!validationResult.valid) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: "Order validation failed",
});
throw new Error(validationResult.reason);
}
const payment = await processPayment(order);
span.setAttribute("payment.id", payment.id);
span.setStatus({ code: SpanStatusCode.OK });
return order;
} catch (err) {
span.recordException(err as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: (err as Error).message,
});
throw err;
} finally {
span.end();
}
});
}Trace-Kontext-Propagation#
Die Magie des Distributed Tracing ist, dass die Trace-ID der Anfrage über Services folgt. Wenn Service A Service B aufruft, wird der Trace-Kontext automatisch in die HTTP-Header injiziert (traceparent-Header gemäß W3C Trace Context Standard).
Für Message Queues musst du manuell propagieren:
import { context, propagation } from "@opentelemetry/api";
// Beim Veröffentlichen einer Nachricht
function publishEvent(queue: string, payload: object) {
const carrier: Record<string, string> = {};
propagation.inject(context.active(), carrier);
messageQueue.publish(queue, {
payload,
traceContext: carrier,
});
}
// Beim Konsumieren einer Nachricht
function consumeEvent(message: QueueMessage) {
const parentContext = propagation.extract(
context.active(),
message.traceContext
);
context.with(parentContext, () => {
tracer.startActiveSpan("processEvent", (span) => {
span.setAttribute("queue.message_id", message.id);
handleEvent(message.payload);
span.end();
});
});
}Wohin Traces schicken#
Jaeger — Die klassische Open-Source-Option. Gute UI, einfach lokal mit Docker zu betreiben.
Grafana Tempo — Wenn du bereits Grafana und Loki verwendest, ist Tempo die natürliche Wahl für Traces.
Grafana Cloud / Datadog / Honeycomb — Wenn du keine Infrastruktur betreiben willst.
Für lokale Entwicklung ist Jaeger in Docker perfekt:
# docker-compose.otel.yml
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # Jaeger UI
- "4318:4318" # OTLP HTTP receiver
environment:
- COLLECTOR_OTLP_ENABLED=trueHealth-Check-Endpoints#
Health Checks sind die einfachste Form der Observability und das Erste, was du implementieren solltest. Sie beantworten eine Frage: „Ist dieser Service in der Lage, gerade Anfragen zu bedienen?"
Drei Arten von Health Checks#
/health — Allgemeine Gesundheit. Läuft der Prozess und ist er responsiv?
/ready — Bereitschaft. Kann dieser Service Traffic verarbeiten? (Hat er sich mit der Datenbank verbunden? Hat er seine Konfiguration geladen?)
/live — Lebendigkeit. Ist der Prozess am Leben und nicht in einem Deadlock?
// src/routes/health.ts
import { Router } from "express";
import { Pool } from "pg";
import Redis from "ioredis";
const router = Router();
interface HealthCheckResult {
status: "ok" | "degraded" | "error";
checks: Record<string, { status: "ok" | "error"; latency?: number; message?: string }>;
uptime: number;
timestamp: string;
version: string;
}
async function checkDatabase(pool: Pool): Promise<{ ok: boolean; latency: number }> {
const start = performance.now();
try {
await pool.query("SELECT 1");
return { ok: true, latency: Math.round(performance.now() - start) };
} catch {
return { ok: false, latency: Math.round(performance.now() - start) };
}
}
async function checkRedis(redis: Redis): Promise<{ ok: boolean; latency: number }> {
const start = performance.now();
try {
await redis.ping();
return { ok: true, latency: Math.round(performance.now() - start) };
} catch {
return { ok: false, latency: Math.round(performance.now() - start) };
}
}
export function createHealthRoutes(pool: Pool, redis: Redis) {
router.get("/live", (_req, res) => {
res.status(200).json({ status: "ok" });
});
router.get("/ready", async (_req, res) => {
const [db, cache] = await Promise.all([
checkDatabase(pool),
checkRedis(redis),
]);
const allOk = db.ok && cache.ok;
res.status(allOk ? 200 : 503).json({
status: allOk ? "ok" : "not_ready",
checks: { database: db, redis: cache },
});
});
router.get("/health", async (_req, res) => {
const [db, cache] = await Promise.all([
checkDatabase(pool),
checkRedis(redis),
]);
const anyError = !db.ok || !cache.ok;
const allError = !db.ok && !cache.ok;
const result: HealthCheckResult = {
status: allError ? "error" : anyError ? "degraded" : "ok",
checks: {
database: {
status: db.ok ? "ok" : "error",
latency: db.latency,
...(!db.ok && { message: "Connection failed" }),
},
redis: {
status: cache.ok ? "ok" : "error",
latency: cache.latency,
...(!cache.ok && { message: "Connection failed" }),
},
},
uptime: process.uptime(),
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || "unknown",
};
res.status(result.status === "error" ? 503 : 200).json(result);
});
return router;
}Alles zusammen: Der vollständige Stack#
Der komplette Observability-Stack, den ich für jede Node.js-Produktions-App verwende:
- Pino für Structured Logging → stdout → PM2/Promtail → Loki
- prom-client für Metriken → Prometheus → Grafana
- OpenTelemetry für Distributed Tracing → Tempo/Jaeger → Grafana
- Health-Check-Endpoints für Bereitschafts- und Lebendigkeitsprüfungen
Alerting-Regeln#
# prometheus/alerts.yml
groups:
- name: node-api
rules:
- alert: HighErrorRate
expr: |
100 * sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
/ sum(rate(nodeapp_http_requests_total[5m])) > 5
for: 2m
labels:
severity: critical
- alert: HighLatency
expr: |
histogram_quantile(0.99,
sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
) > 5
for: 5m
labels:
severity: warning
- alert: EventLoopBlocked
expr: nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"} > 0.1
for: 2m
labels:
severity: warningObservability ist nicht glamourös. Es gibt keine sichtbare Verbesserung für deine Benutzer am Tag, an dem du es einrichtest. Der Wert zeigt sich um 3 Uhr morgens, wenn etwas kaputt geht und du es in 5 Minuten statt 5 Stunden diagnostizierst. Er zeigt sich, wenn du eine Leistungsregression vor dem nächsten Release entdeckst. Er zeigt sich, wenn du einem Kunden genau erklären kannst, warum seine Anfrage 30 Sekunden gedauert hat.
Der Stack, den ich beschrieben habe, deckt 95% dessen ab, was die meisten Teams brauchen. Die restlichen 5% sind Continuous Profiling, eBPF-basierte Instrumentierung und andere fortgeschrittene Techniken, die du brauchst, wenn du Netflix-Größenordnung erreichst. Bis dahin werden dich Logs, Metriken und Traces gut bedienen.