Gå till innehåll
·30 min läsning

Observerbarhet i Node.js: Loggar, mätvärden och traces utan komplexiteten

Strukturerad loggning med Pino, mätvärden med Prometheus, distribuerad tracing med OpenTelemetry. Observerbarhetsstacken jag använder för Node.js-produktionsappar, från noll till full insyn.

Dela:X / TwitterLinkedIn

Jag brukade tro att observerbarhet betydde "lägg till lite console.logs och kolla dem när något går sönder." Det fungerade tills det inte gjorde det. Brytpunkten var en produktionsincident där vårt API returnerade 200:or men datan var inaktuell. Inga fel i loggarna. Inga undantag. Bara tyst felaktiga svar för att en nedströms cache hade blivit inaktuell och ingen märkte det på fyra timmar.

Det var då jag lärde mig skillnaden mellan övervakning och observerbarhet. Övervakning berättar att något är fel. Observerbarhet berättar varför det är fel. Och gapet mellan dessa två är där produktionsincidenter lever.

Det här är observerbarhetsstacken jag har landat i för Node.js-tjänster efter att ha provat de flesta alternativen. Det är inte den mest sofistikerade uppställningen i världen, men den fångar problem innan användare märker dem, och när något ändå slinker igenom kan jag diagnostisera det på minuter istället för timmar.

De tre pelarna, och varför du behöver alla#

Alla pratar om "observerbarhetens tre pelare" — loggar, mätvärden och traces. Vad ingen berättar är att varje pelare svarar på en fundamentalt annorlunda fråga, och du behöver alla tre eftersom ingen enskild pelare kan svara på varje fråga.

Loggar svarar: Vad hände?

En lograd säger "klockan 14:23:07 begärde användare 4821 /api/orders och fick ett 500 för att databasanslutningen gick timeout." Det är en berättelse. Den berättar historien om en specifik händelse.

Mätvärden svarar: Hur mycket händer?

Ett mätvärde säger "under de senaste 5 minuterna var p99-svarstiden 2,3 sekunder och felfrekvensen var 4,7%." Det är aggregerad data. Den berättar om systemets hälsa som helhet, inte om någon individuell begäran.

Traces svarar: Var tog tiden vägen?

En trace säger "den här begäran spenderade 12ms i Express-middlewaren, 3ms på att parsa bodyn, 847ms på att vänta på PostgreSQL och 2ms på att serialisera svaret." Det är ett vattenfall. Den berättar exakt var flaskhalsen är, över tjänstegränser.

Här är den praktiska implikationen: när din larmtelefon ringer klockan 3 på natten är sekvensen nästan alltid densamma.

  1. Mätvärden berättar att något är fel (felfrekvensspik, latensökning)
  2. Loggar berättar vad som händer (specifika felmeddelanden, drabbade endpoints)
  3. Traces berättar varför (vilken nedströms tjänst eller databasfråga som är flaskhalsen)

Om du bara har loggar vet du vad som gick sönder men inte hur allvarligt det är. Om du bara har mätvärden vet du hur allvarligt men inte vad som orsakar det. Om du bara har traces har du vackra vattenfall men inget sätt att veta när du ska titta på dem.

Låt oss bygga var och en.

Strukturerad loggning med Pino#

Varför console.log inte räcker#

Jag vet. Du har använt console.log i produktion och det är "bra." Låt mig visa dig varför det inte är det.

typescript
// Vad du skriver
console.log("User login failed", email, error.message);
 
// Vad som hamnar i din loggfil
// User login failed john@example.com ECONNREFUSED
 
// Försök nu att:
// 1. Söka efter alla inloggningsfel den senaste timmen
// 2. Räkna fel per användare
// 3. Filtrera ut bara ECONNREFUSED-felen
// 4. Korrelera detta med begäran som utlöste det
// Lycka till. Det är en ostrukturerad sträng. Du grepar igenom text.

Strukturerad loggning innebär att varje loggpost är ett JSON-objekt med konsekventa fält. Istället för en mänskligt läsbar sträng som är maskinfiendlig får du ett maskinläsbart objekt som också är mänskligt läsbart (med rätt verktyg).

typescript
// Vad strukturerad loggning ser ut som
{
  "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 kan du fråga detta. level >= 50 AND msg = "User login failed" AND time > now() - 1h ger dig exakt vad du behöver.

Pino kontra Winston#

Jag har använt båda extensivt. Här är den korta versionen:

Winston är mer populärt, mer flexibelt, har fler transporter och är betydligt långsammare. Det uppmuntrar också dåliga mönster — "format"-systemet gör det för enkelt att skapa ostrukturerade, snyggt utskrivna loggar som ser bra ut under utveckling men är oparsbara i produktion.

Pino är snabbare (5-10x i benchmarks), åsiktsfullt om JSON-utdata och följer Unix-filosofin: gör en sak bra (skriv JSON till stdout) och låt andra verktyg hantera resten (transport, formatering, aggregering).

Jag använder Pino. Prestandaskillnaden spelar roll när du loggar tusentals begäranden per sekund, och det åsiktsfulla tillvägagångssättet innebär att varje utvecklare i teamet producerar konsekventa loggar.

Grundläggande Pino-uppställning#

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"),
  // I produktion, bara JSON till stdout. PM2/container-runtime hanterar resten.
  // Under utveckling, använd pino-pretty för mänskligt läsbar utdata.
  ...(isProduction
    ? {}
    : {
        transport: {
          target: "pino-pretty",
          options: {
            colorize: true,
            translateTime: "HH:MM:ss",
            ignore: "pid,hostname",
          },
        },
      }),
  // Standardfält på varje lograd
  base: {
    service: process.env.SERVICE_NAME || "api",
    version: process.env.APP_VERSION || "unknown",
  },
  // Serialisera Error-objekt korrekt
  serializers: {
    err: pino.stdSerializers.err,
    error: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
  // Maskera känsliga fält
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "password",
      "creditCard",
      "ssn",
    ],
    censor: "[REDACTED]",
  },
});

Alternativet redact är kritiskt. Utan det kommer du förr eller senare att logga ett lösenord eller en API-nyckel. Det är inte en fråga om ifall, utan när. Någon utvecklare lägger till logger.info({ body: req.body }, "incoming request") och plötsligt loggar du kreditkortsnummer. Maskering är ditt skyddsnät.

Loggnivåer: Använd dem korrekt#

typescript
// FATAL (60) - Processen håller på att krascha. Väck någon.
logger.fatal({ err }, "Unrecoverable database connection failure");
 
// ERROR (50) - Något misslyckades som inte borde ha. Undersök snart.
logger.error({ err, userId, orderId }, "Payment processing failed");
 
// WARN (40) - Något oväntat men hanterat. Håll ett öga på det.
logger.warn({ retryCount: 3, service: "email" }, "Retry limit approaching");
 
// INFO (30) - Normal drift värd att registrera. "Vad hände"-loggen.
logger.info({ userId, action: "login" }, "User authenticated");
 
// DEBUG (20) - Detaljerad information för felsökning. Aldrig i produktion.
logger.debug({ query, params }, "Database query executing");
 
// TRACE (10) - Extremt detaljerat. Bara när du är desperat.
logger.trace({ headers: req.headers }, "Incoming request headers");

Regeln: om du tvekar mellan INFO och DEBUG är det DEBUG. Om du tvekar mellan WARN och ERROR, fråga dig själv: "Skulle jag vilja bli larmad om det här klockan 3 på natten?" Om ja, ERROR. Om nej, WARN.

Barnloggar och begäranskontext#

Det är här Pino verkligen lyser. En barnlogger ärver alla förälderns inställningar men lägger till extra kontextfält.

typescript
// Varje logg från denna barnlogger kommer att inkludera userId och sessionId
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
 
userLogger.info("User viewed dashboard");
// Utdata inkluderar userId och sessionId automatiskt
 
userLogger.info({ page: "/settings" }, "User navigated");
// Utdata inkluderar userId, sessionId OCH page

För HTTP-servrar vill du ha en barnlogger per begäran så att varje lograd i den begärans livscykel inkluderar begärans ID:

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();
 
  // Koppla en barnlogger till begäran
  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,
  });
 
  // Sätt request-ID-headern på svaret för korrelation
  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 automatisk kontextpropagering#

Barnlogger-ansatsen fungerar, men den kräver att du skickar req.log genom varje funktionsanrop. Det blir tråkigt. AsyncLocalStorage löser detta — det tillhandahåller en kontextlagring som följer det asynkrona exekveringsflödet utan explicit vidarebefordran.

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>();
 
// Hämta den kontextuella loggern var som helst i anropsstacken
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 vilken funktion som helst, var som helst i anropsstacken, hämta den begäransskopade loggern:

typescript
// src/services/order-service.ts
import { getLogger } from "../lib/async-context";
 
export async function processOrder(orderId: string) {
  const log = getLogger(); // Har automatiskt requestId kopplat!
 
  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 };
}
 
// Ingen anledning att skicka loggern som parameter. Det fungerar bara.

Loggaggregering: Vart tar loggarna vägen?#

Under utveckling går loggar till stdout och pino-pretty gör dem läsbara. I produktion är det mer nyanserat.

PM2-vägen#

Om du kör på en VPS med PM2 (som jag tog upp i mitt VPS-inlägg) fångar PM2 stdout automatiskt:

bash
# Visa loggar i realtid
pm2 logs api --lines 100
 
# Loggar lagras i ~/.pm2/logs/
# api-out.log  — stdout (dina JSON-loggar)
# api-error.log — stderr (ofångade undantag, stackspår)

PM2:s inbyggda loggrotation förhindrar diskutrymmesprobllem:

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

Skicka loggar till Loki eller Elasticsearch#

För allt utöver en enskild server behöver du centraliserad loggaggregering. De två huvudalternativen:

Grafana Loki — "Prometheus för loggar." Lättviktigt, indexerar bara etiketter (inte fulltext), fungerar utmärkt med Grafana. Min rekommendation för de flesta team.

Elasticsearch — Fulltextsökning på loggar. Kraftfullare, mer resurskrävande, mer driftoverhead. Använd detta om du genuint behöver fulltextsökning över miljoner loggrader.

För Loki använder den enklaste uppställningen Promtail för att skicka loggar:

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

NDJSON-formatet#

Pino matar som standard ut Newline Delimited JSON (NDJSON) — ett JSON-objekt per rad, separerat med \n. Detta är viktigt eftersom:

  1. Varje loggaggregeringsverktyg förstår det
  2. Det är streambart (du kan bearbeta loggar rad för rad utan att buffra hela filen)
  3. Standard Unix-verktyg fungerar med det: cat api-out.log | jq '.msg' | sort | uniq -c | sort -rn

Konfigurera aldrig Pino att mata ut snyggt utskriven, flerradig JSON i produktion. Du förstör varje verktyg i pipelinen.

typescript
// FEL i produktion — flerradig JSON bryter radbaserad bearbetning
{
  "level": 30,
  "time": 1709312587000,
  "msg": "Request completed"
}
 
// RÄTT i produktion — NDJSON, ett objekt per rad
{"level":30,"time":1709312587000,"msg":"Request completed"}

Mätvärden med Prometheus#

Loggar berättar vad som hände. Mätvärden berättar hur systemet presterar. Skillnaden är som skillnaden mellan att läsa varje transaktion på ditt kontoutdrag och att titta på ditt kontosaldo.

De fyra mätvärdetyperna#

Prometheus har fyra mätvärdetstyper. Att förstå vilken som ska användas när räddar dig från de vanligaste misstagen.

Counter — Ett värde som bara går uppåt. Antal begäranden, antal fel, bearbetade bytes. Återställs till noll vid omstart.

typescript
// "Hur många begäranden har vi betjänat?"
const httpRequestsTotal = new Counter({
  name: "http_requests_total",
  help: "Total number of HTTP requests",
  labelNames: ["method", "route", "status_code"],
});

Gauge — Ett värde som kan gå uppåt och nedåt. Aktuella anslutningar, köstorlek, temperatur, heapanvändning.

typescript
// "Hur många anslutningar är aktiva just nu?"
const activeConnections = new Gauge({
  name: "active_connections",
  help: "Number of currently active connections",
});

Histogram — Observerar värden och räknar dem i konfigurerbara hinkar. Begäranvaraktighet, svarsstorlek. Det är så du får percentiler (p50, p95, p99).

typescript
// "Hur lång tid tar begäranden?" med hinkar vid 10ms, 50ms, 100ms osv.
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 — Liknar Histogram men beräknar kvantiler på klientsidan. Använd Histogram istället om du inte har en specifik anledning att inte göra det. Sammanfattningar kan inte aggregeras över instanser.

Fullständig prom-client-uppställning#

typescript
// src/lib/metrics.ts
import {
  Registry,
  Counter,
  Histogram,
  Gauge,
  collectDefaultMetrics,
} from "prom-client";
 
// Skapa ett anpassat register för att undvika att förorena det globala
export const metricsRegistry = new Registry();
 
// Samla in standard Node.js-mätvärden:
// - 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_",
  // Samla in var 10:e sekund
  gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
});
 
// --- HTTP-mätvärden ---
 
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],
});
 
// --- Affärsmätvärden ---
 
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],
});

Mätvärdesmiddleware#

typescript
// src/middleware/metrics-middleware.ts
import { httpRequestsTotal, httpRequestDuration } from "../lib/metrics";
import type { Request, Response, NextFunction } from "express";
 
// Normalisera rutter för att undvika kardinalitetsexplosion
// /api/users/123 → /api/users/:id
// Utan detta skapar Prometheus en ny tidsserie för varje användar-ID
function normalizeRoute(req: Request): string {
  const route = req.route?.path || req.path;
 
  // Ersätt vanliga dynamiska segment
  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
) {
  // Spåra inte mätvärden för mätvärdesendpointen själv
  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();
}

/metrics-endpointen#

typescript
// src/routes/metrics.ts
import { Router } from "express";
import { metricsRegistry } from "../lib/metrics";
 
const router = Router();
 
router.get("/metrics", async (req, res) => {
  // Grundläggande autentiseringsskydd — exponera inte mätvärden publikt
  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;

Anpassade affärsmätvärden är den riktiga kraften#

Standard Node.js-mätvärdena (heapstorlek, event loop-fördröjning, GC-varaktighet) är grundkrav. De berättar om runtimens hälsa. Men affärsmätvärden berättar om applikationens hälsa.

typescript
// I din ordertjänst
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
 
export async function processOrder(order: Order) {
  try {
    // Tidsmät anropet till betalningsleverantören
    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;
  }
}

En spik i ordersProcessed{status="failed"} berättar något som inga mängder CPU-mätvärden någonsin kan.

Etikettkardinalitet: Den tysta mördaren#

En varning. Varje unik kombination av etikettvärden skapar en ny tidsserie. Om du lägger till en userId-etikett till din HTTP-begäranräknare, och du har 100 000 användare, skapade du just 100 000+ tidsserier. Prometheus stannar.

Regler för etiketter:

  • Bara låg kardinalitet: HTTP-metod (7 värden), statuskod (5 kategorier), rutt (tiotals, inte tusentals)
  • Använd aldrig användar-ID:n, begärans-ID:n, IP-adresser eller tidsstämplar som etikettvärden
  • Om du inte är säker, lägg inte till etiketten. Du kan alltid lägga till den senare, men att ta bort den kräver att du ändrar dashboards och larm

Grafana-dashboards#

Prometheus lagrar datan. Grafana visualiserar den. Här är de paneler jag sätter på varje Node.js-tjänsts dashboard.

Den essentiella dashboarden#

1. Begäranfrekvens (begäranden/sekund)

promql
rate(nodeapp_http_requests_total[5m])

Visar trafikmönstret. Användbart för att upptäcka plötsliga spikar eller fall.

2. Felfrekvens (%)

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

Det viktigaste enskilda talet. Om det går över 1% är något fel.

3. p50 / p95 / p99 Latens

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

p50 berättar den typiska upplevelsen. p99 berättar den sämsta upplevelsen. Om p99 är 10x p50 har du ett svanslatens-problem.

4. Event Loop-fördröjning

promql
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}

Om detta går över 100ms är din event loop blockerad. Förmodligen en synkron operation i en asynkron sökväg.

5. Heapanvändning

promql
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100

Bevaka en stadig uppåttrend — det är en minnesläcka. Spikar under GC är normalt.

6. Aktiva handtag

promql
nodeapp_nodejs_active_handles_total

Öppna filbeskrivare, sockets, timers. Ett kontinuerligt växande antal betyder att du läcker handtag — förmodligen stänger inte databasanslutningar eller HTTP-svar.

Grafana Dashboard som kod#

Du kan versionskontrollera dina dashboards med Grafanas provisionerningsfunktion:

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

Exportera din dashboard-JSON från Grafana, committa den till ditt repo, och din dashboard överlever Grafana-ominstallationer. Detta är inte valfritt för produktion — det är samma princip som infrastruktur som kod.

Distribuerad tracing med OpenTelemetry#

Tracing är den pelare som de flesta team antar sist, och den de önskar att de hade antagit först. När du har flera tjänster som kommunicerar med varandra (även om det bara är "API-server + databas + Redis + externt API"), visar tracing hela bilden av en begärans resa.

Vad är en trace?#

En trace är ett träd av spans. Varje span representerar en arbetsenhet — en HTTP-begäran, en databasfråga, ett funktionsanrop. Spans har en starttid, sluttid, status och attribut. De är länkade tillsammans av ett trace-ID som propageras över tjänstegränser.

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)

En blick berättar: 250ms-begäran spenderade 180ms på att vänta på Stripe. Det är där du optimerar.

OpenTelemetry-uppställning#

OpenTelemetry (OTel) är standarden. Det ersatte det fragmenterade landskapet av Jaeger-klienter, Zipkin-klienter och leverantörsspecifika SDK:er med ett enda, leverantörsneutralt API.

typescript
// src/instrumentation.ts
// Denna fil MÅSTE laddas före alla andra importer.
// I Node.js, använd flaggan --require eller --import.
 
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",
  }),
 
  // Skicka traces till din collector (Jaeger, Tempo osv.)
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
  }),
 
  // Skicka valfritt mätvärden genom OTel också
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
    }),
    exportIntervalMillis: 15000,
  }),
 
  // Auto-instrumentering: skapar automatiskt spans för
  // HTTP-begäranden, Express-rutter, PostgreSQL-frågor, Redis-kommandon,
  // DNS-uppslagningar och mer
  instrumentations: [
    getNodeAutoInstrumentations({
      // Inaktivera bullriga instrumenteringar
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
      // Konfigurera specifika
      "@opentelemetry/instrumentation-http": {
        ignoreIncomingPaths: ["/health", "/ready", "/metrics"],
      },
      "@opentelemetry/instrumentation-express": {
        ignoreLayersType: ["middleware"],
      },
    }),
  ],
});
 
sdk.start();
 
// Graciös avstängning
process.on("SIGTERM", () => {
  sdk.shutdown().then(
    () => console.log("OTel SDK shut down successfully"),
    (err) => console.error("Error shutting down OTel SDK", err)
  );
});

Starta din app med:

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

Det är allt. Utan några ändringar i din applikationskod får du nu traces för varje HTTP-begäran, varje databasfråga, varje Redis-kommando.

Manuellt skapande av spans#

Auto-instrumentering täcker infrastrukturanrop, men ibland vill du traca affärslogik:

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);
 
      // Denna span blir förälder till alla auto-instrumenterade
      // DB-frågor eller HTTP-anrop inuti dessa funktioner
      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-kontextpropagering#

Magin med distribuerad tracing är att trace-ID:t följer begäran över tjänster. När Tjänst A anropar Tjänst B injiceras trace-kontexten automatiskt i HTTP-headrarna (traceparent-header enligt W3C Trace Context-standarden).

Auto-instrumenteringen hanterar detta för utgående HTTP-anrop. Men om du använder en meddelandekö måste du propagera manuellt:

typescript
import { context, propagation } from "@opentelemetry/api";
 
// Vid publicering av ett meddelande
function publishEvent(queue: string, payload: object) {
  const carrier: Record<string, string> = {};
 
  // Injicera aktuell trace-kontext i bäraren
  propagation.inject(context.active(), carrier);
 
  // Skicka både payloaden och trace-kontexten
  messageQueue.publish(queue, {
    payload,
    traceContext: carrier,
  });
}
 
// Vid konsumering av ett meddelande
function consumeEvent(message: QueueMessage) {
  // Extrahera trace-kontexten från meddelandet
  const parentContext = propagation.extract(
    context.active(),
    message.traceContext
  );
 
  // Kör hanteraren inom den extraherade kontexten
  // Nu blir alla spans som skapas här barn till den ursprungliga tracen
  context.with(parentContext, () => {
    tracer.startActiveSpan("processEvent", (span) => {
      span.setAttribute("queue.message_id", message.id);
      handleEvent(message.payload);
      span.end();
    });
  });
}

Vart ska traces skickas#

Jaeger — Det klassiska open source-alternativet. Bra UI, enkelt att köra lokalt med Docker. Begränsad långtidslagring.

Grafana Tempo — Om du redan använder Grafana och Loki är Tempo det naturliga valet för traces. Använder objektlagring (S3/GCS) för kostnadseffektiv långtidsretention.

Grafana Cloud / Datadog / Honeycomb — Om du inte vill drifta infrastruktur. Dyrare, mindre driftoverhead.

För lokal utveckling är Jaeger i Docker perfekt:

yaml
# docker-compose.otel.yml
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"   # Jaeger UI
      - "4318:4318"     # OTLP HTTP-mottagare
    environment:
      - COLLECTOR_OTLP_ENABLED=true

Hälsokontrollendpoints#

Hälsokontroller är den enklaste formen av observerbarhet och det första du bör implementera. De svarar på en fråga: "Kan den här tjänsten hantera begäranden just nu?"

Tre typer av hälsokontroller#

/health — Allmän hälsa. Körs processen och är den responsiv?

/ready — Beredskap. Kan denna tjänst hantera trafik? (Har den anslutit till databasen? Har den laddat sin konfiguration? Har den värmt sin cache?)

/live — Livlighet. Lever processen och är den inte deadlockad? (Kan den svara på en enkel begäran inom en timeout?)

Distinktionen spelar roll för Kubernetes, där livlighetsprober startar om fastlåsta containrar och beredskapsprober tar bort containrar från lastbalanseraren under uppstart eller beroendefel.

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) {
  // Livlighet — kontrollera bara om processen kan svara
  router.get("/live", (_req, res) => {
    res.status(200).json({ status: "ok" });
  });
 
  // Beredskap — kontrollera alla beroenden
  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,
      },
    });
  });
 
  // Fullständig hälsa — detaljerad status för dashboards och felsökning
  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",
    };
 
    // Returnera 200 för ok/degraded (tjänsten kan fortfarande hantera viss trafik)
    // Returnera 503 för error (tjänsten bör tas bort från rotation)
    res.status(result.status === "error" ? 503 : 200).json(result);
  });
 
  return router;
}

Kubernetes Probe-konfiguration#

yaml
# k8s/deployment.yml
spec:
  containers:
    - name: api
      livenessProbe:
        httpGet:
          path: /live
          port: 3000
        initialDelaySeconds: 10
        periodSeconds: 15
        timeoutSeconds: 5
        failureThreshold: 3    # Starta om efter 3 på varandra följande misslyckanden (45s)
      readinessProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 5
        periodSeconds: 10
        timeoutSeconds: 5
        failureThreshold: 2    # Ta bort från LB efter 2 misslyckanden (20s)
      startupProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 0
        periodSeconds: 5
        failureThreshold: 30   # Ge upp till 150s för uppstart

Ett vanligt misstag: att göra livlighetsproben för aggressiv. Om din livlighetsprob kontrollerar databasen, och databasen är tillfälligt nere, startar Kubernetes om din container. Men omstart fixar inte databasen. Nu har du en kraschloop ovanpå ett databasavbrott. Håll livlighetsprober enkla — de bör bara upptäcka deadlockade eller fastlåsta processer.

Felspårning med Sentry#

Loggar fångar fel du förväntade dig. Sentry fångar de du inte gjorde.

Skillnaden är viktig. Du lägger till try/catch-block runt kod du vet kan misslyckas. Men de buggar som spelar mest roll är de i kod du trodde var säker. Ohanterade promise-avvisningar, typfel från oväntade API-svar, null-pekaråtkomst på valfria kedjor som inte var tillräckligt valfria.

Sentry-uppställning för 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",
 
    // Sampla 10% av transaktioner för prestandaövervakning
    // (100% under utveckling)
    tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
 
    // Profilera 100% av samplade transaktioner
    profilesSampleRate: 1.0,
 
    integrations: [
      nodeProfilingIntegration(),
      // Filtrera bort bullriga fel
      Sentry.rewriteFramesIntegration({
        root: process.cwd(),
      }),
    ],
 
    // Skicka inte fel från utveckling
    enabled: process.env.NODE_ENV === "production",
 
    // Filtrera bort kända icke-problem
    ignoreErrors: [
      // Klientfrånkopplingar är inte buggar
      "ECONNRESET",
      "ECONNABORTED",
      "EPIPE",
      // Bottar som skickar skräp
      "SyntaxError: Unexpected token",
    ],
 
    // Ta bort PII innan sändning
    beforeSend(event) {
      // Ta bort IP-adresser
      if (event.request) {
        delete event.request.headers?.["x-forwarded-for"];
        delete event.request.headers?.["x-real-ip"];
        delete event.request.cookies;
      }
 
      // Ta bort känsliga frågeparametrar
      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 felhanterare med 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";
 
// Sentrys begäranhanterare måste komma först
export const sentryRequestHandler = Sentry.Handlers.requestHandler();
 
// Sentrys tracinghanterare
export const sentryTracingHandler = Sentry.Handlers.tracingHandler();
 
// Din anpassade felhanterare kommer sist
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  const log = getLogger();
 
  // Lägg till anpassad kontext till Sentry-händelsen
  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,
        // Skicka inte e-post eller användarnamn till Sentry
      });
    }
 
    // Lägg till breadcrumbs för felsökning
    scope.addBreadcrumb({
      category: "request",
      message: `${req.method} ${req.path}`,
      level: "info",
      data: {
        query: req.query,
        statusCode: res.statusCode,
      },
    });
 
    Sentry.captureException(err);
  });
 
  // Logga felet med full kontext
  log.error(
    {
      err,
      statusCode: 500,
      route: req.route?.path || req.path,
      method: req.method,
    },
    "Unhandled error in request handler"
  );
 
  // Skicka ett generiskt felsvar
  // Exponera aldrig feldetaljer till klienten i produktion
  res.status(500).json({
    error: "Internal Server Error",
    ...(process.env.NODE_ENV !== "production" && {
      message: err.message,
      stack: err.stack,
    }),
  });
}

Source maps#

Utan source maps visar Sentry dig minifierade/transpilerade stackspår. Oanvändbart. Ladda upp source maps under ditt bygge:

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

Eller konfigurera det i din bundler:

typescript
// vite.config.ts (eller motsvarande)
import { sentryVitePlugin } from "@sentry/vite-plugin";
 
export default defineConfig({
  build: {
    sourcemap: true, // Krävs för Sentry
  },
  plugins: [
    sentryVitePlugin({
      org: process.env.SENTRY_ORG,
      project: process.env.SENTRY_PROJECT,
      authToken: process.env.SENTRY_AUTH_TOKEN,
    }),
  ],
});

Kostnaden för ohanterade promise-avvisningar#

Sedan Node.js 15 kraschar ohanterade promise-avvisningar processen som standard. Det är bra — det tvingar dig att hantera fel. Men du behöver ett skyddsnät:

typescript
// src/server.ts — nära toppen av din ingångspunkt
 
process.on("unhandledRejection", (reason, promise) => {
  logger.fatal({ reason, promise }, "Unhandled promise rejection — crashing");
  Sentry.captureException(reason);
 
  // Fluscha Sentry-händelser innan krasch
  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);
  });
});

Den viktiga delen: Sentry.flush() före process.exit(). Utan det kanske felhändelsen inte hinner till Sentry innan processen dör.

Larmning: De larm som faktiskt spelar roll#

Att ha 200 Prometheus-mätvärden och noll larm är bara fåfäng övervakning. Att ha 50 larm som utlöses varje dag är larmtrötthet — du börjar ignorera dem, och sedan missar du det som spelar roll.

Målet är ett litet antal högkvalitativa larm som innebär "något är genuint fel och en människa behöver titta på det."

Prometheus AlertManager-konfiguration#

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 larm som faktiskt väcker mig#

yaml
# prometheus/rules/node-api.yml
groups:
  - name: node-api-critical
    rules:
      # Hög felfrekvens — något är trasigt
      - 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"
 
      # Långsamma svar — användare lider
      - 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 }}"
 
      # Minnesläcka — kommer att OOM-a snart
      - 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 }}"
 
      # Processen är nere
      - 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 blir långsam
      - 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 }}"
 
      # Trafiken sjönk markant — möjligt routingproblem
      - 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"
 
      # Databasfrågor blir långsamma
      - 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 }}"
 
      # Externt API misslyckas
      - 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%"

Notera for-klausulen på varje larm. Utan den utlöser en enstaka spik ett larm. En 5-minuters for innebär att villkoret måste vara sant i 5 sammanhängande minuter. Detta eliminerar brus från tillfälliga blippar.

Larmhygien#

Varje larm måste klara detta test:

  1. Är det åtgärdbart? Om ingen kan göra något åt det, larma inte. Logga det, visa det på en dashboard, men väck inte någon.
  2. Kräver det mänskligt ingripande? Om det självläker (som en kort nätverksblipp) bör for-klausulen filtrera bort det.
  3. Har det utlösts under de senaste 30 dagarna? Om inte kan det vara felkonfigurerat eller tröskeln felaktig. Granska det.
  4. När det utlöses, bryr folk sig? Om teamet regelbundet avfärdar det, ta bort det eller justera tröskeln.

Jag granskar mina larm kvartalsvis. Varje larm får ett av tre utfall: behåll, justera tröskel eller radera.

Att sätta ihop allt: Express-applikationen#

Så här passar alla delar ihop i en verklig applikation:

typescript
// src/server.ts
import { initSentry } from "./lib/sentry";
 
// Initiera Sentry först — före andra importer
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);
 
// --- Middleware-ordning spelar roll ---
 
// 1. Sentrys begäranhanterare (måste vara först)
app.use(sentryRequestHandler);
app.use(sentryTracingHandler);
 
// 2. Asynkron kontext (skapar begäransskopat kontext)
app.use(asyncContextMiddleware);
 
// 3. Begäransloggning
app.use(requestLogger);
 
// 4. Mätvärdesinsamling
app.use(metricsMiddleware);
 
// 5. Body-parsning
app.use(express.json({ limit: "1mb" }));
 
// --- Rutter ---
 
// Hälsokontroller (ingen autentisering krävs)
app.use(createHealthRoutes(pool, redis));
 
// Mätvärden (grundläggande autentiseringsskyddad)
app.use(metricsRouter);
 
// API-rutter
app.use("/api", apiRouter);
 
// --- Felhantering ---
 
// Sentrys felhanterare (måste vara före anpassad felhanterare)
app.use(Sentry.Handlers.errorHandler());
 
// Anpassad felhanterare (måste vara sist)
app.use(errorHandler);
 
// --- Start ---
 
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"
  );
});
 
// Graciös avstängning
async function shutdown(signal: string) {
  logger.info({ signal }, "Shutdown signal received");
 
  // Sluta acceptera nya anslutningar
  // Bearbeta pågående begäranden (Express gör detta automatiskt)
 
  // Stäng databaspoolen
  await pool.end().catch((err) => {
    logger.error({ err }, "Error closing database pool");
  });
 
  // Stäng Redis-anslutningen
  await redis.quit().catch((err) => {
    logger.error({ err }, "Error closing Redis connection");
  });
 
  // Fluscha Sentry
  await Sentry.close(2000);
 
  logger.info("Shutdown complete");
  process.exit(0);
}
 
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

Den minimala fungerande stacken#

Allt ovan är den "fullständiga" stacken. Du behöver inte allt på dag ett. Så här skalar du din observerbarhet i takt med att ditt projekt växer.

Steg 1: Sidoprojekt / Ensam utvecklare#

Du behöver tre saker:

  1. Strukturerade konsollloggar — Använd Pino, mata ut JSON till stdout. Även om du bara läser dem med pm2 logs är JSON-loggar sökbara och parsbara.

  2. En /health-endpoint — Tar 5 minuter att implementera, räddar dig när du felsöker "körs den ens?"

  3. Sentry gratisnivå — Fångar fel du inte förutsåg. Gratisnivån ger dig 5 000 händelser/månad, vilket räcker för ett sidoprojekt.

typescript
// Det här är den minimala uppställningen. Under 50 rader. Inga ursäkter.
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"));

Steg 2: Växande projekt / Litet team#

Lägg till:

  1. Prometheus-mätvärden + Grafana — När "det känns långsamt" inte räcker och du behöver data. Börja med begäranfrekvens, felfrekvens och p99-latens.

  2. Loggaggregering — När ssh till servern och grep:a genom filer slutar skala. Loki + Promtail om du redan använder Grafana.

  3. Grundläggande larm — Felfrekvens > 1%, p99 > 1s, tjänst nere. Tre larm. Det är allt.

Steg 3: Produktionstjänst / Flera tjänster#

Lägg till:

  1. Distribuerad tracing med OpenTelemetry — När "API:et är långsamt" blir "vilken av de 5 tjänsterna det anropar är långsam?" OTel auto-instrumentering ger dig 80% av värdet utan kodändringar.

  2. Dashboard som kod — Versionskontrollera dina Grafana-dashboards. Du kommer att tacka dig själv när du behöver återskapa dem.

  3. Strukturerad larmning — AlertManager med ordentlig routing, eskalering och tystningsregler.

  4. Affärsmätvärden — Ordrar/sekund, konverteringsgrad, ködjup. Mätvärdena ditt produktteam bryr sig om.

Vad du bör skippa#

  • APM-leverantörer med per-värd-prissättning — I skala är kostnaden absurd. Öppen källkod (Prometheus + Grafana + Tempo + Loki) ger dig 95% av funktionaliteten.
  • Loggnivåer under INFO i produktion — Du genererar terabytes med DEBUG-loggar och betalar för lagringen. Använd DEBUG bara vid aktiv felsökning, stäng sedan av det.
  • Anpassade mätvärden för allt — Börja med RED-metoden (Rate, Errors, Duration) för varje tjänst. Lägg bara till anpassade mätvärden när du har en specifik fråga att besvara.
  • Komplex trace-sampling — Börja med en enkel samplingsfrekvens (10% i produktion). Adaptiv sampling är en för tidig optimering för de flesta team.

Avslutande tankar#

Observerbarhet är inte en produkt du köper eller ett verktyg du installerar. Det är en praxis. Det är skillnaden mellan att driva din tjänst och att hoppas att din tjänst driver sig själv.

Stacken jag har beskrivit här — Pino för loggar, Prometheus för mätvärden, OpenTelemetry för traces, Sentry för fel, Grafana för visualisering, AlertManager för larm — är inte den enklaste möjliga uppställningen. Men varje del förtjänar sin plats genom att besvara en fråga som de andra delarna inte kan.

Börja med strukturerade loggar och en hälsoendpoint. Lägg till mätvärden när du behöver veta "hur illa är det." Lägg till traces när du behöver veta "var tar tiden vägen." Varje lager bygger på det förra, och inget av dem kräver att du skriver om din applikation.

Den bästa tiden att lägga till observerbarhet var före din senaste produktionsincident. Den näst bästa tiden är nu.

Relaterade inlägg