Ga naar inhoud
·31 min leestijd

Observability in Node.js: Logs, Metrics en Traces Zonder de Complexiteit

Gestructureerde logging met Pino, metrics met Prometheus, distributed tracing met OpenTelemetry. De observability-stack die ik gebruik voor Node.js-productieapps, van nul tot volledig inzicht.

Delen:X / TwitterLinkedIn

Ik dacht vroeger dat observability betekende "gooi er wat console.logs in en bekijk ze wanneer er iets kapotgaat." Dat werkte prima, totdat het dat niet meer deed. Het breekpunt was een productie-incident waarbij onze API 200-statuscodes retourneerde, maar de data verouderd was. Geen fouten in de logs. Geen exceptions. Gewoon stilletjes verkeerde responses, omdat een downstream cache verouderd was geraakt en niemand het vier uur lang had opgemerkt.

Toen leerde ik het verschil tussen monitoring en observability. Monitoring vertelt je dat er iets mis is. Observability vertelt je waarom het mis is. En het gat tussen die twee is waar productie-incidenten leven.

Dit is de observability-stack waar ik uiteindelijk op uitgekomen ben voor Node.js-services, na het uitproberen van de meeste alternatieven. Het is niet de meest geavanceerde setup ter wereld, maar het vangt problemen op voordat gebruikers ze opmerken, en wanneer er toch iets doorglipt, kan ik het in minuten diagnosticeren in plaats van uren.

De Drie Pijlers, en Waarom Je Ze Alle Drie Nodig Hebt#

Iedereen praat over de "drie pijlers van observability" — logs, metrics en traces. Wat niemand je vertelt is dat elke pijler een fundamenteel andere vraag beantwoordt, en je hebt ze alle drie nodig omdat geen enkele pijler elke vraag kan beantwoorden.

Logs beantwoorden: Wat is er gebeurd?

Een logregel zegt "om 14:23:07 heeft gebruiker 4821 /api/orders opgevraagd en een 500 gekregen omdat de databaseverbinding een time-out had." Het is een verhaal. Het vertelt je het verhaal van één specifiek event.

Metrics beantwoorden: Hoeveel gebeurt er?

Een metric zegt "over de laatste 5 minuten was de p99-responstijd 2,3 seconden en het foutpercentage was 4,7%." Het zijn geaggregeerde data. Het vertelt je over de gezondheid van het systeem als geheel, niet over een individueel verzoek.

Traces beantwoorden: Waar ging de tijd naartoe?

Een trace zegt "dit verzoek besteedde 12ms in de Express-middleware, 3ms aan het parsen van de body, 847ms aan het wachten op PostgreSQL, en 2ms aan het serialiseren van de response." Het is een waterval. Het vertelt je precies waar het knelpunt zit, over servicegrenzen heen.

Dit is de praktische implicatie: wanneer je pieper 's nachts om 3 uur afgaat, is de volgorde vrijwel altijd hetzelfde.

  1. Metrics vertellen je dat er iets mis is (piek in foutpercentage, toename in latency)
  2. Logs vertellen je wat er aan de hand is (specifieke foutmeldingen, getroffen endpoints)
  3. Traces vertellen je waarom (welke downstream service of databasequery het knelpunt is)

Als je alleen logs hebt, weet je wat er kapotging maar niet hoe erg het is. Als je alleen metrics hebt, weet je hoe erg maar niet wat de oorzaak is. Als je alleen traces hebt, heb je prachtige watervallen maar geen manier om te weten wanneer je ernaar moet kijken.

Laten we ze stuk voor stuk bouwen.

Gestructureerde Logging met Pino#

Waarom console.log Niet Genoeg Is#

Ik weet het. Je gebruikt console.log in productie en het is "prima." Laat me je laten zien waarom het dat niet is.

typescript
// Wat je schrijft
console.log("User login failed", email, error.message);
 
// Wat er in je logbestand terechtkomt
// User login failed john@example.com ECONNREFUSED
 
// Probeer nu eens om:
// 1. Alle inlogfouten van het laatste uur te zoeken
// 2. Fouten per gebruiker te tellen
// 3. Alleen de ECONNREFUSED-fouten eruit te filteren
// 4. Dit te correleren met het verzoek dat het veroorzaakte
// Succes ermee. Het is een ongestructureerde string. Je bent door tekst aan het greppen.

Gestructureerde logging betekent dat elk logitem een JSON-object is met consistente velden. In plaats van een menselijk leesbare string die vijandig is voor machines, krijg je een machinaal leesbaar object dat ook menselijk leesbaar is (met de juiste tools).

typescript
// Hoe gestructureerde logging eruitziet
{
  "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
}

Nu kun je dit queryen. level >= 50 AND msg = "User login failed" AND time > now() - 1h geeft je precies wat je nodig hebt.

Pino vs Winston#

Ik heb beide uitgebreid gebruikt. Hier is de korte versie:

Winston is populairder, flexibeler, heeft meer transports, en is aanzienlijk langzamer. Het moedigt ook slechte patronen aan — het "format"-systeem maakt het te makkelijk om ongestructureerde, mooi opgemaakte logs te maken die er in ontwikkeling leuk uitzien maar in productie onparseerbaar zijn.

Pino is sneller (5-10x in benchmarks), eigenwijs over JSON-output, en volgt de Unix-filosofie: doe één ding goed (schrijf JSON naar stdout) en laat andere tools de rest afhandelen (transport, opmaak, aggregatie).

Ik gebruik Pino. Het prestatieverschil is belangrijk wanneer je duizenden verzoeken per seconde logt, en de eigenzinnige aanpak zorgt ervoor dat elke ontwikkelaar in het team consistente logs produceert.

Basis Pino Setup#

typescript
// 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 productie, gewoon JSON naar stdout. PM2/container runtime handelt de rest af.
  // In development, gebruik pino-pretty voor menselijk leesbare output.
  ...(isProduction
    ? {}
    : {
        transport: {
          target: "pino-pretty",
          options: {
            colorize: true,
            translateTime: "HH:MM:ss",
            ignore: "pid,hostname",
          },
        },
      }),
  // Standaardvelden op elke logregel
  base: {
    service: process.env.SERVICE_NAME || "api",
    version: process.env.APP_VERSION || "unknown",
  },
  // Error-objecten correct serialiseren
  serializers: {
    err: pino.stdSerializers.err,
    error: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
  // Gevoelige velden redigeren
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "password",
      "creditCard",
      "ssn",
    ],
    censor: "[REDACTED]",
  },
});

De redact-optie is cruciaal. Zonder die optie zul je uiteindelijk een wachtwoord of API-sleutel loggen. Het is geen kwestie van of, maar van wanneer. Een ontwikkelaar zal op een gegeven moment logger.info({ body: req.body }, "incoming request") toevoegen en plotseling log je creditcardnummers. Redactie is je vangnet.

Logniveaus: Gebruik Ze Goed#

typescript
// FATAL (60) - Het proces gaat crashen. Maak iemand wakker.
logger.fatal({ err }, "Unrecoverable database connection failure");
 
// ERROR (50) - Iets is mislukt dat niet had moeten mislukken. Onderzoek snel.
logger.error({ err, userId, orderId }, "Payment processing failed");
 
// WARN (40) - Iets onverwachts maar afgehandeld. Houd het in de gaten.
logger.warn({ retryCount: 3, service: "email" }, "Retry limit approaching");
 
// INFO (30) - Normale operaties die het waard zijn om vast te leggen. Het "wat er gebeurde" log.
logger.info({ userId, action: "login" }, "User authenticated");
 
// DEBUG (20) - Gedetailleerde informatie voor debuggen. Nooit in productie.
logger.debug({ query, params }, "Database query executing");
 
// TRACE (10) - Extreem gedetailleerd. Alleen als je wanhopig bent.
logger.trace({ headers: req.headers }, "Incoming request headers");

De regel: als je twijfelt tussen INFO en DEBUG, dan is het DEBUG. Als je twijfelt tussen WARN en ERROR, stel jezelf dan de vraag: "Zou ik hier om 3 uur 's nachts over gewaarschuwd willen worden?" Zo ja, ERROR. Zo nee, WARN.

Child Loggers en Request Context#

Dit is waar Pino echt uitblinkt. Een child logger erft alle configuratie van de parent maar voegt extra contextvelden toe.

typescript
// Elk log van deze child logger bevat automatisch userId en sessionId
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
 
userLogger.info("User viewed dashboard");
// Output bevat userId en sessionId automatisch
 
userLogger.info({ page: "/settings" }, "User navigated");
// Output bevat userId, sessionId, EN page

Voor HTTP-servers wil je een child logger per verzoek, zodat elke logregel in de levenscyclus van dat verzoek het request-ID bevat:

typescript
// 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();
 
  // Koppel een child logger aan het verzoek
  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,
  });
 
  // Zet het request-ID header op de response voor correlatie
  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 voor Automatische Contextpropagatie#

De child logger-aanpak werkt, maar het vereist dat je req.log door elke functieaanroep heen doorgeeft. Dat wordt vervelend. AsyncLocalStorage lost dit op — het biedt een contextopslag die de asynchrone uitvoeringsstroom volgt zonder expliciet doorgeven.

typescript
// 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>();
 
// Haal de contextuele logger op van waar dan ook in de call stack
export function getLogger(): Logger {
  const store = asyncContext.getStore();
  return store?.logger || logger;
}
 
export function getRequestId(): string | undefined {
  return asyncContext.getStore()?.requestId;
}
typescript
// 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();
  });
}

Nu kan elke functie, waar dan ook in de call stack, de request-scoped logger ophalen:

typescript
// src/services/order-service.ts
import { getLogger } from "../lib/async-context";
 
export async function processOrder(orderId: string) {
  const log = getLogger(); // Heeft automatisch requestId erbij!
 
  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 };
}
 
// Geen noodzaak om de logger als parameter door te geven. Het werkt gewoon.

Logaggregatie: Waar Gaan Logs Naartoe?#

In development gaan logs naar stdout en pino-pretty maakt ze leesbaar. In productie is het genuanceerder.

De PM2-Route#

Als je draait op een VPS met PM2 (wat ik behandeld heb in mijn VPS-setup post), vangt PM2 stdout automatisch op:

bash
# Bekijk logs in real-time
pm2 logs api --lines 100
 
# Logs worden opgeslagen in ~/.pm2/logs/
# api-out.log  — stdout (je JSON-logs)
# api-error.log — stderr (uncaught exceptions, stack traces)

PM2's ingebouwde logrotatie voorkomt problemen met schijfruimte:

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress true

Logs Versturen naar Loki of Elasticsearch#

Voor alles wat meer is dan een enkele server heb je gecentraliseerde logaggregatie nodig. De twee belangrijkste opties:

Grafana Loki — De "Prometheus voor logs." Lichtgewicht, indexeert alleen labels (geen volledige tekst), werkt prachtig samen met Grafana. Mijn aanbeveling voor de meeste teams.

Elasticsearch — Volledige-tekstzoekopdrachten op logs. Krachtiger, meer resources nodig, meer operationele overhead. Gebruik dit als je oprecht volledige-tekstzoekopdrachten nodig hebt over miljoenen logregels.

Voor Loki is de eenvoudigste setup met Promtail om logs te versturen:

yaml
# /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: UnixMs

Het NDJSON-formaat#

Pino schrijft standaard Newline Delimited JSON (NDJSON) — één JSON-object per regel, gescheiden door \n. Dit is belangrijk omdat:

  1. Elk logaggregatietool het begrijpt
  2. Het streambaar is (je kunt logs regel voor regel verwerken zonder het hele bestand te bufferen)
  3. Standaard Unix-tools ermee werken: cat api-out.log | jq '.msg' | sort | uniq -c | sort -rn

Configureer Pino nooit om mooi opgemaakte, multi-line JSON uit te voeren in productie. Je breekt elke tool in de pipeline.

typescript
// FOUT in productie — multi-line JSON breekt regelgebaseerde verwerking
{
  "level": 30,
  "time": 1709312587000,
  "msg": "Request completed"
}
 
// GOED in productie — NDJSON, één object per regel
{"level":30,"time":1709312587000,"msg":"Request completed"}

Metrics met Prometheus#

Logs vertellen je wat er is gebeurd. Metrics vertellen je hoe het systeem presteert. Het verschil is vergelijkbaar met het verschil tussen het lezen van elke transactie op je bankafschrift versus het bekijken van je rekeningsaldo.

De Vier Metriektypen#

Prometheus heeft vier metriektypen. Begrijpen welk type je wanneer moet gebruiken, bespaart je de meest voorkomende fouten.

Counter — Een waarde die alleen omhoog gaat. Aantal verzoeken, aantal fouten, verwerkte bytes. Reset naar nul bij herstart.

typescript
// "Hoeveel verzoeken hebben we verwerkt?"
const httpRequestsTotal = new Counter({
  name: "http_requests_total",
  help: "Total number of HTTP requests",
  labelNames: ["method", "route", "status_code"],
});

Gauge — Een waarde die omhoog en omlaag kan gaan. Huidige verbindingen, wachtrijgrootte, temperatuur, heap-gebruik.

typescript
// "Hoeveel verbindingen zijn er nu actief?"
const activeConnections = new Gauge({
  name: "active_connections",
  help: "Number of currently active connections",
});

Histogram — Observeert waarden en telt ze in configureerbare buckets. Verzoekduur, responsegrootte. Dit is hoe je percentielen krijgt (p50, p95, p99).

typescript
// "Hoe lang duren verzoeken?" met buckets bij 10ms, 50ms, 100ms, etc.
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 — Vergelijkbaar met Histogram maar berekent kwantielen aan de clientzijde. Gebruik Histogram tenzij je een specifieke reden hebt om dat niet te doen. Summaries kunnen niet geaggregeerd worden over instanties heen.

Volledige prom-client Setup#

typescript
// src/lib/metrics.ts
import {
  Registry,
  Counter,
  Histogram,
  Gauge,
  collectDefaultMetrics,
} from "prom-client";
 
// Maak een aangepast register aan om het globale niet te vervuilen
export const metricsRegistry = new Registry();
 
// Verzamel standaard Node.js-metrics:
// - process_cpu_seconds_total
// - process_resident_memory_bytes
// - nodejs_heap_size_total_bytes
// - nodejs_active_handles_total
// - nodejs_eventloop_lag_seconds
// - nodejs_gc_duration_seconds
collectDefaultMetrics({
  register: metricsRegistry,
  prefix: "nodeapp_",
  // Verzamel elke 10 seconden
  gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
});
 
// --- HTTP Metrics ---
 
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],
});
 
export const httpRequestSizeBytes = new Histogram({
  name: "nodeapp_http_request_size_bytes",
  help: "Size of HTTP request bodies in bytes",
  labelNames: ["method", "route"] as const,
  buckets: [100, 1000, 10000, 100000, 1000000],
  registers: [metricsRegistry],
});
 
// --- Zakelijke Metrics ---
 
export const ordersProcessed = new Counter({
  name: "nodeapp_orders_processed_total",
  help: "Total number of orders processed",
  labelNames: ["status"] as const, // "success", "failed", "refunded"
  registers: [metricsRegistry],
});
 
export const activeWebSocketConnections = new Gauge({
  name: "nodeapp_active_websocket_connections",
  help: "Number of active WebSocket connections",
  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],
});

De Metrics Middleware#

typescript
// src/middleware/metrics-middleware.ts
import { httpRequestsTotal, httpRequestDuration } from "../lib/metrics";
import type { Request, Response, NextFunction } from "express";
 
// Normaliseer routes om cardinaliteitsexplosie te voorkomen
// /api/users/123 → /api/users/:id
// Zonder dit maakt Prometheus een nieuwe tijdreeks aan voor elk gebruikers-ID
function normalizeRoute(req: Request): string {
  const route = req.route?.path || req.path;
 
  // Vervang veelvoorkomende dynamische segmenten
  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
) {
  // Track geen metrics voor het metrics-endpoint zelf
  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();
}

Het /metrics Endpoint#

typescript
// src/routes/metrics.ts
import { Router } from "express";
import { metricsRegistry } from "../lib/metrics";
 
const router = Router();
 
router.get("/metrics", async (req, res) => {
  // Basic auth beveiliging — stel metrics niet publiek beschikbaar
  const authHeader = req.headers.authorization;
  const expected = `Basic ${Buffer.from(
    `${process.env.METRICS_USER}:${process.env.METRICS_PASSWORD}`
  ).toString("base64")}`;
 
  if (!authHeader || authHeader !== expected) {
    res.status(401).set("WWW-Authenticate", "Basic").send("Unauthorized");
    return;
  }
 
  try {
    const metrics = await metricsRegistry.metrics();
    res.set("Content-Type", metricsRegistry.contentType);
    res.send(metrics);
  } catch (err) {
    res.status(500).send("Error collecting metrics");
  }
});
 
export default router;

Aangepaste Zakelijke Metrics Zijn de Echte Kracht#

De standaard Node.js-metrics (heap-grootte, event loop lag, GC-duur) zijn de basis. Ze vertellen je over de runtime-gezondheid. Maar zakelijke metrics vertellen je over de applicatie-gezondheid.

typescript
// In je orderservice
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
 
export async function processOrder(order: Order) {
  try {
    // Meet de duur van de betalingsprovider-aanroep
    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" });
 
    externalApiDuration.startTimer({
      service: "stripe",
      endpoint: "charges.create",
    })({ status: "error" });
 
    throw err;
  }
}

Een piek in ordersProcessed{status="failed"} vertelt je iets dat geen enkele hoeveelheid CPU-metrics ooit zal doen.

Labelcardinaliteit: De Stille Moordenaar#

Een waarschuwing. Elke unieke combinatie van labelwaarden creëert een nieuwe tijdreeks. Als je een userId-label toevoegt aan je HTTP-verzoekteller, en je hebt 100.000 gebruikers, dan heb je zojuist 100.000+ tijdreeksen gecreëerd. Prometheus gaat vastlopen.

Regels voor labels:

  • Alleen lage cardinaliteit: HTTP-methode (7 waarden), statuscode (5 categorieën), route (tientallen, niet duizenden)
  • Gebruik nooit gebruikers-ID's, request-ID's, IP-adressen of timestamps als labelwaarden
  • Als je het niet zeker weet, voeg het label niet toe. Je kunt het later altijd toevoegen, maar verwijderen vereist het aanpassen van dashboards en alerts

Grafana Dashboards#

Prometheus slaat de data op. Grafana visualiseert het. Dit zijn de panelen die ik op elk Node.js-service dashboard zet.

Het Essentiële Dashboard#

1. Request Rate (verzoeken/seconde)

promql
rate(nodeapp_http_requests_total[5m])

Toont het verkeerspatroon. Nuttig voor het spotten van plotselinge pieken of dalingen.

2. Foutpercentage (%)

promql
100 * (
  sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
  /
  sum(rate(nodeapp_http_requests_total[5m]))
)

Het allerbelangrijkste getal. Als dit boven de 1% komt, is er iets mis.

3. p50 / p95 / p99 Latency

promql
histogram_quantile(0.99,
  sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
)

p50 vertelt je de typische ervaring. p99 vertelt je de slechtste ervaring. Als p99 10x de p50 is, heb je een tail latency-probleem.

4. Event Loop Lag

promql
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}

Als dit boven 100ms komt, is je event loop geblokkeerd. Waarschijnlijk een synchrone operatie in een asynchroon pad.

5. Heap-gebruik

promql
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100

Let op een gestage stijgende trend — dat is een geheugenlek. Pieken tijdens GC zijn normaal.

6. Actieve Handles

promql
nodeapp_nodejs_active_handles_total

Open file descriptors, sockets, timers. Een continu groeiend getal betekent dat je handles lekt — waarschijnlijk databaseverbindingen of HTTP-responses die niet worden gesloten.

Grafana Dashboard als Code#

Je kunt je dashboards versiebeheren met Grafana's provisioning-functie:

yaml
# /etc/grafana/provisioning/dashboards/dashboards.yml
apiVersion: 1
providers:
  - name: "Node.js Services"
    orgId: 1
    folder: "Services"
    type: file
    disableDeletion: false
    editable: true
    options:
      path: /var/lib/grafana/dashboards
      foldersFromFilesStructure: true

Exporteer je dashboard-JSON uit Grafana, commit het naar je repo, en je dashboard overleeft Grafana-herinstallaties. Dit is niet optioneel voor productie — het is hetzelfde principe als infrastructure as code.

Distributed Tracing met OpenTelemetry#

Tracing is de pijler die de meeste teams als laatste adopteren, en de pijler waarvan ze wensen dat ze die als eerste hadden geadopteerd. Wanneer je meerdere services hebt die met elkaar communiceren (zelfs als het alleen maar "API-server + database + Redis + externe API" is), laat tracing je het volledige plaatje zien van de reis van een verzoek.

Wat Is een Trace?#

Een trace is een boom van spans. Elke span vertegenwoordigt een eenheid van werk — een HTTP-verzoek, een databasequery, een functieaanroep. Spans hebben een starttijd, eindtijd, status en attributen. Ze zijn aan elkaar gekoppeld door een trace-ID dat over servicegrenzen heen wordt doorgegeven.

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)

Eén blik vertelt je: het verzoek van 250ms besteedde 180ms aan het wachten op Stripe. Daar moet je optimaliseren.

OpenTelemetry Setup#

OpenTelemetry (OTel) is de standaard. Het verving het gefragmenteerde landschap van Jaeger-clients, Zipkin-clients en leverancierspecifieke SDK's met een enkele, leverancierneutrale API.

typescript
// src/instrumentation.ts
// Dit bestand MOET geladen worden vóór alle andere imports.
// In Node.js, gebruik de --require of --import vlag.
 
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",
  }),
 
  // Stuur traces naar je collector (Jaeger, Tempo, etc.)
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
  }),
 
  // Optioneel ook metrics via OTel versturen
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
    }),
    exportIntervalMillis: 15000,
  }),
 
  // Auto-instrumentatie: maakt automatisch spans aan voor
  // HTTP-verzoeken, Express-routes, PostgreSQL-queries, Redis-commando's,
  // DNS-lookups en meer
  instrumentations: [
    getNodeAutoInstrumentations({
      // Schakel lawaaierige instrumentaties uit
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
      // Configureer specifieke instrumentaties
      "@opentelemetry/instrumentation-http": {
        ignoreIncomingPaths: ["/health", "/ready", "/metrics"],
      },
      "@opentelemetry/instrumentation-express": {
        ignoreLayersType: ["middleware"],
      },
    }),
  ],
});
 
sdk.start();
 
// Graceful shutdown
process.on("SIGTERM", () => {
  sdk.shutdown().then(
    () => console.log("OTel SDK shut down successfully"),
    (err) => console.error("Error shutting down OTel SDK", err)
  );
});

Start je app met:

bash
node --import ./src/instrumentation.ts ./src/server.ts

Dat is het. Met nul wijzigingen aan je applicatiecode heb je nu traces voor elk HTTP-verzoek, elke databasequery, elk Redis-commando.

Handmatige Span-creatie#

Auto-instrumentatie dekt infrastructuuraanroepen, maar soms wil je bedrijfslogica tracen:

typescript
// 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);
 
      // Deze span wordt de parent van alle auto-geïnstrumenteerde
      // DB-queries of HTTP-aanroepen binnen deze functies
      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 Context Propagatie#

De magie van distributed tracing is dat het trace-ID het verzoek volgt over services heen. Wanneer Service A Service B aanroept, wordt de trace-context automatisch geïnjecteerd in de HTTP-headers (traceparent-header volgens de W3C Trace Context-standaard).

De auto-instrumentatie regelt dit voor uitgaande HTTP-aanroepen. Maar als je een message queue gebruikt, moet je handmatig propageren:

typescript
import { context, propagation } from "@opentelemetry/api";
 
// Bij het publiceren van een bericht
function publishEvent(queue: string, payload: object) {
  const carrier: Record<string, string> = {};
 
  // Injecteer huidige trace-context in de carrier
  propagation.inject(context.active(), carrier);
 
  // Verstuur zowel de payload als de trace-context
  messageQueue.publish(queue, {
    payload,
    traceContext: carrier,
  });
}
 
// Bij het consumeren van een bericht
function consumeEvent(message: QueueMessage) {
  // Extraheer de trace-context uit het bericht
  const parentContext = propagation.extract(
    context.active(),
    message.traceContext
  );
 
  // Voer de handler uit binnen de geëxtraheerde context
  // Nu worden alle spans die hier worden aangemaakt kinderen van de originele trace
  context.with(parentContext, () => {
    tracer.startActiveSpan("processEvent", (span) => {
      span.setAttribute("queue.message_id", message.id);
      handleEvent(message.payload);
      span.end();
    });
  });
}

Waar Traces Naartoe Sturen#

Jaeger — De klassieke open-source optie. Goede UI, makkelijk lokaal te draaien met Docker. Beperkte langetermijnopslag.

Grafana Tempo — Als je al Grafana en Loki gebruikt, is Tempo de logische keuze voor traces. Gebruikt objectopslag (S3/GCS) voor kosteneffectieve langetermijnretentie.

Grafana Cloud / Datadog / Honeycomb — Als je geen infrastructuur wilt beheren. Duurder, minder operationele overhead.

Voor lokale development is Jaeger in Docker perfect:

yaml
# 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=true

Health Check Endpoints#

Health checks zijn de eenvoudigste vorm van observability en het eerste dat je moet implementeren. Ze beantwoorden één vraag: "Is deze service op dit moment in staat om verzoeken te verwerken?"

Drie Soorten Health Checks#

/health — Algemene gezondheid. Draait het proces en reageert het?

/ready — Gereedheid. Kan deze service verkeer verwerken? (Is de databaseverbinding gemaakt? Is de configuratie geladen? Is de cache opgewarmd?)

/live — Levendigheid. Is het proces actief en niet vastgelopen? (Kan het binnen een timeout op een simpel verzoek reageren?)

Het onderscheid is belangrijk voor Kubernetes, waar liveness probes vastgelopen containers herstarten en readiness probes containers verwijderen van de load balancer tijdens het opstarten of bij afhankelijkheidsproblemen.

typescript
// 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) {
  // Liveness — controleer alleen of het proces kan reageren
  router.get("/live", (_req, res) => {
    res.status(200).json({ status: "ok" });
  });
 
  // Readiness — controleer alle afhankelijkheden
  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,
      },
    });
  });
 
  // Volledig health — gedetailleerde status voor dashboards en debugging
  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",
    };
 
    // Retourneer 200 voor ok/degraded (service kan nog steeds wat verkeer verwerken)
    // Retourneer 503 voor error (service moet uit de rotatie worden gehaald)
    res.status(result.status === "error" ? 503 : 200).json(result);
  });
 
  return router;
}

Kubernetes Probe Configuratie#

yaml
# k8s/deployment.yml
spec:
  containers:
    - name: api
      livenessProbe:
        httpGet:
          path: /live
          port: 3000
        initialDelaySeconds: 10
        periodSeconds: 15
        timeoutSeconds: 5
        failureThreshold: 3    # Herstart na 3 opeenvolgende fouten (45s)
      readinessProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 5
        periodSeconds: 10
        timeoutSeconds: 5
        failureThreshold: 2    # Verwijder uit LB na 2 fouten (20s)
      startupProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 0
        periodSeconds: 5
        failureThreshold: 30   # Geef tot 150s voor het opstarten

Een veelgemaakte fout: de liveness probe te agressief maken. Als je liveness probe de database controleert, en de database is tijdelijk down, dan herstart Kubernetes je container. Maar herstarten repareert de database niet. Nu heb je een crash loop bovenop een database-storing. Houd liveness probes simpel — ze moeten alleen vastgelopen of geblokkeerde processen detecteren.

Error Tracking met Sentry#

Logs vangen fouten op die je verwachtte. Sentry vangt de fouten op die je niet verwachtte.

Het verschil is belangrijk. Je voegt try/catch-blokken toe rond code waarvan je weet dat die kan falen. Maar de bugs die er het meest toe doen zijn die in code waarvan je dacht dat die veilig was. Onafgehandelde promise rejections, type errors door onverwachte API-responses, null pointer access op optional chains die niet optioneel genoeg waren.

Sentry Setup voor Node.js#

typescript
// src/lib/sentry.ts
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
 
export function initSentry() {
  Sentry.init({
    dsn: process.env.SENTRY_DSN,
    environment: process.env.NODE_ENV || "development",
    release: process.env.APP_VERSION || "unknown",
 
    // Sample 10% van de transacties voor performance monitoring
    // (100% in development)
    tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
 
    // Profileer 100% van gesamplede transacties
    profilesSampleRate: 1.0,
 
    integrations: [
      nodeProfilingIntegration(),
      // Filter lawaaierige fouten eruit
      Sentry.rewriteFramesIntegration({
        root: process.cwd(),
      }),
    ],
 
    // Stuur geen fouten vanuit development
    enabled: process.env.NODE_ENV === "production",
 
    // Filter bekende niet-problemen eruit
    ignoreErrors: [
      // Client-disconnects zijn geen bugs
      "ECONNRESET",
      "ECONNABORTED",
      "EPIPE",
      // Bots die rommel sturen
      "SyntaxError: Unexpected token",
    ],
 
    // Verwijder PII voordat het verstuurd wordt
    beforeSend(event) {
      // Verwijder IP-adressen
      if (event.request) {
        delete event.request.headers?.["x-forwarded-for"];
        delete event.request.headers?.["x-real-ip"];
        delete event.request.cookies;
      }
 
      // Verwijder gevoelige queryparameters
      if (event.request?.query_string) {
        const params = new URLSearchParams(event.request.query_string);
        params.delete("token");
        params.delete("api_key");
        event.request.query_string = params.toString();
      }
 
      return event;
    },
  });
}

Express Error Handler met Sentry#

typescript
// src/middleware/error-handler.ts
import * as Sentry from "@sentry/node";
import { getLogger } from "../lib/async-context";
import type { Request, Response, NextFunction } from "express";
 
// Sentry request handler moet als eerste komen
export const sentryRequestHandler = Sentry.Handlers.requestHandler();
 
// Sentry tracing handler
export const sentryTracingHandler = Sentry.Handlers.tracingHandler();
 
// Je eigen error handler komt als laatste
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  const log = getLogger();
 
  // Voeg aangepaste context toe aan het Sentry-event
  Sentry.withScope((scope) => {
    scope.setTag("route", req.route?.path || req.path);
    scope.setTag("method", req.method);
 
    if (req.user) {
      scope.setUser({
        id: req.user.id,
        // Stuur geen e-mail of gebruikersnaam naar Sentry
      });
    }
 
    // Voeg breadcrumbs toe voor debugging
    scope.addBreadcrumb({
      category: "request",
      message: `${req.method} ${req.path}`,
      level: "info",
      data: {
        query: req.query,
        statusCode: res.statusCode,
      },
    });
 
    Sentry.captureException(err);
  });
 
  // Log de fout met volledige context
  log.error(
    {
      err,
      statusCode: 500,
      route: req.route?.path || req.path,
      method: req.method,
    },
    "Unhandled error in request handler"
  );
 
  // Stuur een generieke foutresponse
  // Onthul nooit foutdetails aan de client in productie
  res.status(500).json({
    error: "Internal Server Error",
    ...(process.env.NODE_ENV !== "production" && {
      message: err.message,
      stack: err.stack,
    }),
  });
}

Source Maps#

Zonder source maps toont Sentry je geminificeerde/getranspileerde stack traces. Nutteloos. Upload source maps tijdens je build:

bash
# In je CI/CD-pipeline
npx @sentry/cli sourcemaps upload \
  --org your-org \
  --project your-project \
  --release $APP_VERSION \
  ./dist

Of configureer het in je bundler:

typescript
// vite.config.ts (of equivalent)
import { sentryVitePlugin } from "@sentry/vite-plugin";
 
export default defineConfig({
  build: {
    sourcemap: true, // Vereist voor Sentry
  },
  plugins: [
    sentryVitePlugin({
      org: process.env.SENTRY_ORG,
      project: process.env.SENTRY_PROJECT,
      authToken: process.env.SENTRY_AUTH_TOKEN,
    }),
  ],
});

De Kosten van Onafgehandelde Promise Rejections#

Sinds Node.js 15 crashen onafgehandelde promise rejections het proces standaard. Dit is goed — het dwingt je om fouten af te handelen. Maar je hebt een vangnet nodig:

typescript
// src/server.ts — bovenaan je entrypoint
 
process.on("unhandledRejection", (reason, promise) => {
  logger.fatal({ reason, promise }, "Unhandled promise rejection — crashing");
  Sentry.captureException(reason);
 
  // Flush Sentry-events voordat het crasht
  Sentry.flush(2000).finally(() => {
    process.exit(1);
  });
});
 
process.on("uncaughtException", (error) => {
  logger.fatal({ err: error }, "Uncaught exception — crashing");
  Sentry.captureException(error);
 
  Sentry.flush(2000).finally(() => {
    process.exit(1);
  });
});

Het belangrijke deel: Sentry.flush() vóór process.exit(). Zonder dat kan het fout-event Sentry mogelijk niet bereiken voordat het proces afsluit.

Alerting: De Alerts Die Er Echt Toe Doen#

200 Prometheus-metrics en nul alerts hebben is ijdelheidsmonitoring. 50 alerts hebben die elke dag afgaan is alertmoeheid — je gaat ze negeren, en dan mis je degene die er wel toe doet.

Het doel is een klein aantal alerts met een hoog signaal die betekenen "er is echt iets mis en een mens moet ernaar kijken."

Prometheus AlertManager Configuratie#

yaml
# alertmanager.yml
global:
  resolve_timeout: 5m
  slack_api_url: $SLACK_WEBHOOK_URL
 
route:
  receiver: "slack-warnings"
  group_by: ["alertname", "service"]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - match:
        severity: critical
      receiver: "pagerduty-critical"
      repeat_interval: 15m
    - match:
        severity: warning
      receiver: "slack-warnings"
 
receivers:
  - name: "pagerduty-critical"
    pagerduty_configs:
      - routing_key: $PAGERDUTY_ROUTING_KEY
        severity: critical
  - name: "slack-warnings"
    slack_configs:
      - channel: "#alerts"
        title: '{{ template "slack.title" . }}'
        text: '{{ template "slack.text" . }}'

De Alerts Waar Ik Echt Wakker Van Word#

yaml
# prometheus/rules/node-api.yml
groups:
  - name: node-api-critical
    rules:
      # Hoog foutpercentage — er is iets kapot
      - alert: HighErrorRate
        expr: |
          (
            sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
            /
            sum(rate(nodeapp_http_requests_total[5m]))
          ) > 0.01
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Error rate above 1% for 5 minutes"
          description: "{{ $value | humanizePercentage }} of requests are returning 5xx"
 
      # Trage responses — gebruikers lijden eronder
      - alert: HighP99Latency
        expr: |
          histogram_quantile(0.99,
            sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
          ) > 1
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "p99 latency above 1 second for 5 minutes"
          description: "p99 latency is {{ $value | humanizeDuration }}"
 
      # Geheugenlek — gaat binnenkort OOM
      - alert: HighHeapUsage
        expr: |
          (
            nodeapp_nodejs_heap_size_used_bytes
            /
            nodeapp_nodejs_heap_size_total_bytes
          ) > 0.80
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "Heap usage above 80% for 10 minutes"
          description: "Heap usage is at {{ $value | humanizePercentage }}"
 
      # Proces is down
      - alert: ServiceDown
        expr: up{job="node-api"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Node.js API is down"
 
  - name: node-api-warnings
    rules:
      # Event loop wordt traag
      - alert: HighEventLoopLag
        expr: |
          nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"} > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Event loop lag above 100ms"
          description: "p99 event loop lag is {{ $value | humanizeDuration }}"
 
      # Verkeer is aanzienlijk gedaald — mogelijk routeringsprobleem
      - alert: TrafficDrop
        expr: |
          sum(rate(nodeapp_http_requests_total[5m]))
          < (sum(rate(nodeapp_http_requests_total[5m] offset 1h)) * 0.5)
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Traffic dropped by more than 50% compared to 1 hour ago"
 
      # Databasequeries worden traag
      - alert: SlowDatabaseQueries
        expr: |
          histogram_quantile(0.99,
            sum(rate(nodeapp_db_query_duration_seconds_bucket[5m])) by (le, operation)
          ) > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "p99 database query time above 500ms"
          description: "Slow {{ $labels.operation }} queries: {{ $value | humanizeDuration }}"
 
      # Externe API faalt
      - alert: ExternalAPIFailures
        expr: |
          (
            sum(rate(nodeapp_external_api_duration_seconds_count{status="error"}[5m])) by (service)
            /
            sum(rate(nodeapp_external_api_duration_seconds_count[5m])) by (service)
          ) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "External API {{ $labels.service }} failing >10%"

Let op de for-clausule bij elke alert. Zonder die clausule triggert een enkele piek een alert. Een for van 5 minuten betekent dat de conditie 5 aaneengesloten minuten waar moet zijn. Dit elimineert ruis van momentane blips.

Alerthygiëne#

Elke alert moet deze test doorstaan:

  1. Is het actionable? Als niemand er iets aan kan doen, geef dan geen alert. Log het, zet het op een dashboard, maar maak er niemand wakker voor.
  2. Vereist het menselijke tussenkomst? Als het zichzelf herstelt (zoals een kort netwerkprobleem), zou de for-clausule het moeten wegfilteren.
  3. Is het de afgelopen 30 dagen afgegaan? Zo niet, dan is het mogelijk verkeerd geconfigureerd of is de drempel onjuist. Controleer het.
  4. Wanneer het afgaat, vinden mensen het belangrijk? Als het team het regelmatig wegklikt, verwijder het of pas de drempel aan.

Ik audit mijn alerts elk kwartaal. Elke alert krijgt één van drie uitkomsten: behouden, drempel aanpassen, of verwijderen.

Alles Samenvoegen: De Express-applicatie#

Dit is hoe alle puzzelstukjes samenkomen in een echte applicatie:

typescript
// src/server.ts
import { initSentry } from "./lib/sentry";
 
// Initialiseer Sentry eerst — vóór andere imports
initSentry();
 
import express from "express";
import * as Sentry from "@sentry/node";
import { Pool } from "pg";
import Redis from "ioredis";
import { logger } from "./lib/logger";
import { asyncContextMiddleware } from "./middleware/async-context-middleware";
import { metricsMiddleware } from "./middleware/metrics-middleware";
import { requestLogger } from "./middleware/request-logger";
import {
  sentryRequestHandler,
  sentryTracingHandler,
  errorHandler,
} from "./middleware/error-handler";
import { createHealthRoutes } from "./routes/health";
import metricsRouter from "./routes/metrics";
import apiRouter from "./routes/api";
 
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);
 
// --- De Volgorde van Middleware Is Belangrijk ---
 
// 1. Sentry request handler (moet als eerste)
app.use(sentryRequestHandler);
app.use(sentryTracingHandler);
 
// 2. Async context (creëert request-scoped context)
app.use(asyncContextMiddleware);
 
// 3. Request logging
app.use(requestLogger);
 
// 4. Metrics verzameling
app.use(metricsMiddleware);
 
// 5. Body parsing
app.use(express.json({ limit: "1mb" }));
 
// --- Routes ---
 
// Health checks (geen auth vereist)
app.use(createHealthRoutes(pool, redis));
 
// Metrics (basic auth beveiligd)
app.use(metricsRouter);
 
// API-routes
app.use("/api", apiRouter);
 
// --- Error Handling ---
 
// Sentry error handler (moet vóór aangepaste error handler)
app.use(Sentry.Handlers.errorHandler());
 
// Aangepaste error handler (moet als laatste)
app.use(errorHandler);
 
// --- Starten ---
 
const port = parseInt(process.env.PORT || "3000", 10);
 
app.listen(port, () => {
  logger.info(
    {
      port,
      nodeEnv: process.env.NODE_ENV,
      version: process.env.APP_VERSION,
    },
    "Server started"
  );
});
 
// Graceful shutdown
async function shutdown(signal: string) {
  logger.info({ signal }, "Shutdown signal received");
 
  // Stop met het accepteren van nieuwe verbindingen
  // Verwerk lopende verzoeken (Express doet dit automatisch)
 
  // Sluit de databasepool
  await pool.end().catch((err) => {
    logger.error({ err }, "Error closing database pool");
  });
 
  // Sluit de Redis-verbinding
  await redis.quit().catch((err) => {
    logger.error({ err }, "Error closing Redis connection");
  });
 
  // Flush Sentry
  await Sentry.close(2000);
 
  logger.info("Shutdown complete");
  process.exit(0);
}
 
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

De Minimaal Levensvatbare Stack#

Alles hierboven is de "volledige" stack. Je hebt niet alles nodig op dag één. Dit is hoe je je observability opschaalt naarmate je project groeit.

Fase 1: Hobbyproject / Solo Ontwikkelaar#

Je hebt drie dingen nodig:

  1. Gestructureerde console-logs — Gebruik Pino, output JSON naar stdout. Zelfs als je ze alleen leest met pm2 logs, zijn JSON-logs doorzoekbaar en parseerbaar.

  2. Een /health endpoint — Kost 5 minuten om te implementeren, redt je wanneer je zit te debuggen "draait het eigenlijk wel?"

  3. Sentry gratis tier — Vangt de fouten op die je niet had voorzien. De gratis tier geeft je 5.000 events/maand, wat ruim voldoende is voor een hobbyproject.

typescript
// Dit is de minimale setup. Minder dan 50 regels. Geen excuses.
import pino from "pino";
import express from "express";
import * as Sentry from "@sentry/node";
 
const logger = pino({ level: "info" });
const app = express();
 
Sentry.init({ dsn: process.env.SENTRY_DSN });
app.use(Sentry.Handlers.requestHandler());
 
app.get("/health", (_req, res) => {
  res.json({ status: "ok", uptime: process.uptime() });
});
 
app.use("/api", apiRoutes);
 
app.use(Sentry.Handlers.errorHandler());
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  logger.error({ err }, "Unhandled error");
  res.status(500).json({ error: "Internal Server Error" });
});
 
app.listen(3000, () => logger.info("Server started on port 3000"));

Fase 2: Groeiend Project / Klein Team#

Voeg toe:

  1. Prometheus metrics + Grafana — Wanneer "het voelt traag" niet meer genoeg is en je data nodig hebt. Begin met request rate, error rate en p99 latency.

  2. Logaggregatie — Wanneer ssh'en naar de server en greppen door bestanden niet meer schaalt. Loki + Promtail als je al Grafana gebruikt.

  3. Basis alerts — Foutpercentage > 1%, p99 > 1s, service down. Drie alerts. Dat is alles.

Fase 3: Productieservice / Meerdere Services#

Voeg toe:

  1. Distributed tracing met OpenTelemetry — Wanneer "de API is traag" verandert in "welke van de 5 services die hij aanroept is traag?" OTel auto-instrumentatie geeft je 80% van de waarde met nul codewijzigingen.

  2. Dashboard als code — Versiebeheer je Grafana-dashboards. Je zult jezelf dankbaar zijn wanneer je ze opnieuw moet aanmaken.

  3. Gestructureerde alerting — AlertManager met juiste routing, escalatie en stilteregels.

  4. Zakelijke metrics — Bestellingen/seconde, conversieratio, wachtrijdiepte. De metrics waar je productteam om geeft.

Wat Je Kunt Overslaan#

  • APM-leveranciers met prijzen per host — Op schaal zijn de kosten absurd. Open source (Prometheus + Grafana + Tempo + Loki) geeft je 95% van de functionaliteit.
  • Logniveaus onder INFO in productie — Je genereert terabytes aan DEBUG-logs en betaalt voor de opslag. Gebruik DEBUG alleen wanneer je actief problemen onderzoekt, en schakel het dan weer uit.
  • Aangepaste metrics voor alles — Begin met de RED-methode (Rate, Errors, Duration) voor elke service. Voeg aangepaste metrics alleen toe wanneer je een specifieke vraag te beantwoorden hebt.
  • Complexe trace sampling — Begin met een eenvoudige sample rate (10% in productie). Adaptieve sampling is een premature optimalisatie voor de meeste teams.

Afsluitende Gedachten#

Observability is geen product dat je koopt of een tool die je installeert. Het is een praktijk. Het is het verschil tussen je service draaien en hopen dat je service zichzelf draait.

De stack die ik hier heb beschreven — Pino voor logs, Prometheus voor metrics, OpenTelemetry voor traces, Sentry voor fouten, Grafana voor visualisatie, AlertManager voor alerts — is niet de meest eenvoudige setup die mogelijk is. Maar elk onderdeel verdient zijn plek door een vraag te beantwoorden die de andere onderdelen niet kunnen beantwoorden.

Begin met gestructureerde logs en een health endpoint. Voeg metrics toe wanneer je moet weten "hoe erg is het." Voeg traces toe wanneer je moet weten "waar gaat de tijd naartoe." Elke laag bouwt voort op de vorige, en geen ervan vereist dat je je applicatie herschrijft.

Het beste moment om observability toe te voegen was vóór je laatste productie-incident. Het op één na beste moment is nu.

Gerelateerde artikelen