Přeskočit na obsah
·30 min čtení

Observabilita v Node.js: Logy, metriky a traces bez složitosti

Strukturované logování s Pino, metriky s Prometheus, distribuovaný tracing s OpenTelemetry. Stack pro observabilitu, který používám pro produkční Node.js aplikace, od nuly po plnou viditelnost.

Sdílet:X / TwitterLinkedIn

Dříve jsem si myslel, že observabilita znamená „přidej pár console.logů a zkontroluj je, když se něco rozbije." To fungovalo, dokud to nefungovalo. Zlomový bod byl produkční incident, kdy naše API vracelo 200ky, ale data byla zastaralá. Žádné chyby v logách. Žádné výjimky. Jen tiše špatné odpovědi, protože downstream cache zastarala a nikdo si toho čtyři hodiny nevšiml.

Tehdy jsem pochopil rozdíl mezi monitoringem a observabilitou. Monitoring vám řekne, že je něco špatně. Observabilita vám řekne, proč je to špatně. A propast mezi těmito dvěma je místo, kde žijí produkční incidenty.

Toto je stack observability, na kterém jsem se ustálil pro Node.js služby po vyzkoušení většiny alternativ. Není to nejsofistikovanější setup na světě, ale zachytí problémy dříve, než si jich uživatelé všimnou, a když něco proklouzne, dokážu to diagnostikovat během minut místo hodin.

Tři pilíře a proč potřebujete všechny#

Všichni mluví o „třech pilířích observability" — logy, metriky a traces. Co vám nikdo neřekne, je, že každý pilíř odpovídá na zásadně jinou otázku a všechny tři potřebujete, protože žádný jednotlivý pilíř nemůže odpovědět na každou otázku.

Logy odpovídají: Co se stalo?

Řádek logu říká „ve 14:23:07 uživatel 4821 požádal o /api/orders a dostal 500, protože vypršel časový limit připojení k databázi." Je to narativ. Říká vám příběh jedné konkrétní události.

Metriky odpovídají: Kolik se toho děje?

Metrika říká „za posledních 5 minut byl p99 doby odezvy 2,3 sekundy a míra chyb 4,7 %." Jsou to agregovaná data. Říkají vám o zdraví systému jako celku, ne o jakémkoli individuálním požadavku.

Traces odpovídají: Kde se ztratil čas?

Trace říká „tento požadavek strávil 12 ms v Express middleware, 3 ms parsováním těla, 847 ms čekáním na PostgreSQL a 2 ms serializací odpovědi." Je to vodopád. Říká vám přesně, kde je úzké hrdlo, napříč hranicemi služeb.

Zde je praktický důsledek: když vás v 3 ráno probudí pager, sekvence je téměř vždy stejná.

  1. Metriky vám řeknou, že je něco špatně (nárůst míry chyb, zvýšení latence)
  2. Logy vám řeknou, co se děje (konkrétní chybové zprávy, dotčené endpointy)
  3. Traces vám řeknou proč (která downstream služba nebo databázový dotaz je úzké hrdlo)

Pokud máte jen logy, budete vědět co se rozbilo, ale ne jak moc to je. Pokud máte jen metriky, budete vědět jak moc, ale ne co to způsobuje. Pokud máte jen traces, budete mít krásné vodopády, ale žádný způsob, jak vědět, kdy se na ně podívat.

Pojďme si jednotlivé pilíře vybudovat.

Strukturované logování s Pino#

Proč console.log nestačí#

Vím. Používáte console.log v produkci a je to „v pořádku." Dovolte mi ukázat, proč to tak není.

typescript
// What you write
console.log("User login failed", email, error.message);
 
// What ends up in your log file
// User login failed john@example.com ECONNREFUSED
 
// Now try to:
// 1. Search for all login failures in the last hour
// 2. Count failures per user
// 3. Filter out just the ECONNREFUSED errors
// 4. Correlate this with the request that triggered it
// Good luck. It's an unstructured string. You're grepping through text.

Strukturované logování znamená, že každý záznam v logu je JSON objekt s konzistentními poli. Místo člověku čitelného řetězce, který je nepřátelský pro stroje, dostanete strojově čitelný objekt, který je čitelný i pro lidi (s odpovídajícími nástroji).

typescript
// What structured logging looks like
{
  "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
}

Teď se na to můžete dotazovat. level >= 50 AND msg = "User login failed" AND time > now() - 1h vám dá přesně to, co potřebujete.

Pino vs Winston#

Oba jsem rozsáhle používal. Zde je stručná verze:

Winston je populárnější, flexibilnější, má více transportů a je výrazně pomalejší. Také podporuje špatné návyky — systém „formátů" příliš usnadňuje vytváření nestrukturovaných, hezky formátovaných logů, které vypadají pěkně ve vývoji, ale jsou neparsovatelné v produkci.

Pino je rychlejší (5-10x v benchmarcích), má jasný názor na JSON výstup a řídí se filozofií Unixu: dělej jednu věc dobře (zapisuj JSON na stdout) a nech ostatní nástroje zvládnout zbytek (transport, formátování, agregaci).

Používám Pino. Rozdíl ve výkonu hraje roli, když logujete tisíce požadavků za sekundu, a názorový přístup znamená, že každý vývojář v týmu produkuje konzistentní logy.

Základní nastavení Pino#

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 production, just JSON to stdout. PM2/container runtime handles the rest.
  // In development, use pino-pretty for human-readable output.
  ...(isProduction
    ? {}
    : {
        transport: {
          target: "pino-pretty",
          options: {
            colorize: true,
            translateTime: "HH:MM:ss",
            ignore: "pid,hostname",
          },
        },
      }),
  // Standard fields on every log line
  base: {
    service: process.env.SERVICE_NAME || "api",
    version: process.env.APP_VERSION || "unknown",
  },
  // Serialize Error objects properly
  serializers: {
    err: pino.stdSerializers.err,
    error: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
  // Redact sensitive fields
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "password",
      "creditCard",
      "ssn",
    ],
    censor: "[REDACTED]",
  },
});

Volba redact je kritická. Bez ní dříve či později zalogujete heslo nebo API klíč. Není to otázka jestli, ale kdy. Nějaký vývojář přidá logger.info({ body: req.body }, "incoming request") a najednou logujete čísla kreditních karet. Redakce je vaše záchranná síť.

Úrovně logování: Používejte je správně#

typescript
// FATAL (60) - The process is about to crash. Wake someone up.
logger.fatal({ err }, "Unrecoverable database connection failure");
 
// ERROR (50) - Something failed that shouldn't have. Investigate soon.
logger.error({ err, userId, orderId }, "Payment processing failed");
 
// WARN (40) - Something unexpected but handled. Keep an eye on it.
logger.warn({ retryCount: 3, service: "email" }, "Retry limit approaching");
 
// INFO (30) - Normal operations worth recording. The "what happened" log.
logger.info({ userId, action: "login" }, "User authenticated");
 
// DEBUG (20) - Detailed information for debugging. Never in production.
logger.debug({ query, params }, "Database query executing");
 
// TRACE (10) - Extremely detailed. Only when you're desperate.
logger.trace({ headers: req.headers }, "Incoming request headers");

Pravidlo: pokud váháte mezi INFO a DEBUG, je to DEBUG. Pokud váháte mezi WARN a ERROR, zeptejte se sami sebe: „Chtěl bych na tohle být upozorněn ve 3 ráno?" Pokud ano, ERROR. Pokud ne, WARN.

Child loggery a kontext požadavku#

Toto je místo, kde Pino skutečně září. Child logger dědí veškerou konfiguraci rodiče, ale přidává extra kontextová pole.

typescript
// Every log from this child logger will include userId and sessionId
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
 
userLogger.info("User viewed dashboard");
// Output includes userId and sessionId automatically
 
userLogger.info({ page: "/settings" }, "User navigated");
// Output includes userId, sessionId, AND page

Pro HTTP servery chcete child logger na každý požadavek, aby každý řádek logu v životním cyklu toho požadavku obsahoval ID požadavku:

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();
 
  // Attach a child logger to the request
  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,
  });
 
  // Set the request ID header on the response for correlation
  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 pro automatickou propagaci kontextu#

Přístup s child loggerem funguje, ale vyžaduje, abyste předávali req.log každým voláním funkce. To je únavné. AsyncLocalStorage to řeší — poskytuje kontextové úložiště, které sleduje asynchronní tok provádění bez explicitního předávání.

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>();
 
// Get the contextual logger from anywhere in the 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();
  });
}

Nyní jakákoli funkce, kdekoli v zásobníku volání, může získat logger se scopem požadavku:

typescript
// src/services/order-service.ts
import { getLogger } from "../lib/async-context";
 
export async function processOrder(orderId: string) {
  const log = getLogger(); // Automatically has requestId attached!
 
  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 };
}
 
// No need to pass logger as parameter. It just works.

Agregace logů: Kam logy putují?#

Ve vývoji logy jdou na stdout a pino-pretty je udělá čitelné. V produkci je to složitější.

Cesta PM2#

Pokud běžíte na VPS s PM2 (což jsem pokryl ve svém příspěvku o nastavení VPS), PM2 automaticky zachytává stdout:

bash
# View logs in real-time
pm2 logs api --lines 100
 
# Logs are stored at ~/.pm2/logs/
# api-out.log  — stdout (your JSON logs)
# api-error.log — stderr (uncaught exceptions, stack traces)

Vestavěná rotace logů PM2 zabraňuje problémům s místem na disku:

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

Odesílání logů do Loki nebo Elasticsearch#

Pro cokoli nad rámec jednoho serveru potřebujete centralizovanou agregaci logů. Dvě hlavní možnosti:

Grafana Loki — „Prometheus pro logy." Lehký, indexuje pouze štítky (ne plný text), krásně spolupracuje s Grafanou. Mé doporučení pro většinu týmů.

Elasticsearch — Fulltextové vyhledávání v logách. Výkonnější, náročnější na zdroje, větší provozní režie. Použijte, pokud skutečně potřebujete fulltextové vyhledávání přes miliony řádků logů.

Pro Loki je nejjednodušší nastavení pomocí Promtailu pro odesílání logů:

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

Formát NDJSON#

Pino ve výchozím nastavení produkuje Newline Delimited JSON (NDJSON) — jeden JSON objekt na řádek, oddělený \n. To je důležité, protože:

  1. Každý nástroj pro agregaci logů to rozumí
  2. Je to streamovatelné (můžete zpracovávat logy řádek po řádku bez bufferování celého souboru)
  3. Standardní Unixové nástroje na tom fungují: cat api-out.log | jq '.msg' | sort | uniq -c | sort -rn

Nikdy nekonfigurujte Pino pro výstup hezky formátovaného, víceřádkového JSONu v produkci. Rozbijete tím každý nástroj v pipeline.

typescript
// WRONG in production — multi-line JSON breaks line-based processing
{
  "level": 30,
  "time": 1709312587000,
  "msg": "Request completed"
}
 
// RIGHT in production — NDJSON, one object per line
{"level":30,"time":1709312587000,"msg":"Request completed"}

Metriky s Prometheus#

Logy vám řeknou, co se stalo. Metriky vám řeknou, jak systém performuje. Rozdíl je jako rozdíl mezi čtením každé transakce na výpisu z účtu versus pohledem na zůstatek vašeho účtu.

Čtyři typy metrik#

Prometheus má čtyři typy metrik. Pochopení, kdy který použít, vám ušetří nejběžnější chyby.

Counter — Hodnota, která jen roste. Počet požadavků, počet chyb, zpracované bajty. Po restartu se resetuje na nulu.

typescript
// "How many requests have we served?"
const httpRequestsTotal = new Counter({
  name: "http_requests_total",
  help: "Total number of HTTP requests",
  labelNames: ["method", "route", "status_code"],
});

Gauge — Hodnota, která může růst i klesat. Aktuální připojení, velikost fronty, teplota, využití haldy.

typescript
// "How many connections are active right now?"
const activeConnections = new Gauge({
  name: "active_connections",
  help: "Number of currently active connections",
});

Histogram — Pozoruje hodnoty a počítá je v konfigurovatelných bucketech. Doba trvání požadavku, velikost odpovědi. Takto získáte percentily (p50, p95, p99).

typescript
// "How long do requests take?" with buckets at 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 — Podobný histogramu, ale počítá kvantily na straně klienta. Použijte raději histogram, pokud nemáte konkrétní důvod. Summary nelze agregovat napříč instancemi.

Kompletní nastavení prom-client#

typescript
// src/lib/metrics.ts
import {
  Registry,
  Counter,
  Histogram,
  Gauge,
  collectDefaultMetrics,
} from "prom-client";
 
// Create a custom registry to avoid polluting the global one
export const metricsRegistry = new Registry();
 
// Collect default 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_",
  // Collect every 10 seconds
  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],
});
 
// --- Business 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],
});

Middleware pro metriky#

typescript
// src/middleware/metrics-middleware.ts
import { httpRequestsTotal, httpRequestDuration } from "../lib/metrics";
import type { Request, Response, NextFunction } from "express";
 
// Normalize routes to avoid cardinality explosion
// /api/users/123 → /api/users/:id
// Without this, Prometheus will create a new time series for every user ID
function normalizeRoute(req: Request): string {
  const route = req.route?.path || req.path;
 
  // Replace common dynamic segments
  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
) {
  // Don't track metrics for the metrics endpoint itself
  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();
}

Endpoint /metrics#

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 protection — don't expose metrics publicly
  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;

Vlastní business metriky jsou skutečná síla#

Výchozí metriky Node.js (velikost haldy, zpoždění event loopu, doba GC) jsou základ. Říkají vám o zdraví runtime. Ale business metriky vám říkají o zdraví aplikace.

typescript
// In your order service
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
 
export async function processOrder(order: Order) {
  try {
    // Time the payment provider call
    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;
  }
}

Nárůst v ordersProcessed{status="failed"} vám řekne něco, co žádné množství CPU metrik nikdy neřekne.

Kardinalita štítků: Tichý zabiják#

Slovo varování. Každá unikátní kombinace hodnot štítků vytvoří novou časovou řadu. Pokud přidáte štítek userId k počítadlu HTTP požadavků a máte 100 000 uživatelů, právě jste vytvořili 100 000+ časových řad. Prometheus se zastaví.

Pravidla pro štítky:

  • Pouze nízká kardinalita: HTTP metoda (7 hodnot), stavový kód (5 kategorií), route (desítky, ne tisíce)
  • Nikdy nepoužívejte ID uživatelů, ID požadavků, IP adresy nebo časová razítka jako hodnoty štítků
  • Pokud si nejste jistí, štítek nepřidávejte. Přidat ho můžete vždy později, ale jeho odstranění vyžaduje změnu dashboardů a alertů

Dashboardy v Grafaně#

Prometheus ukládá data. Grafana je vizualizuje. Zde jsou panely, které dávám na každý dashboard Node.js služby.

Základní dashboard#

1. Míra požadavků (požadavky/sekundu)

promql
rate(nodeapp_http_requests_total[5m])

Ukazuje vzorec provozu. Užitečné pro zachycení náhlých špiček nebo propadů.

2. Míra chyb (%)

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

Jedno nejdůležitější číslo. Pokud překročí 1 %, něco je špatně.

3. Latence p50 / p95 / p99

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

p50 vám řekne typický zážitek. p99 vám řekne nejhorší zážitek. Pokud je p99 10x vyšší než p50, máte problém s tail latencí.

4. Zpoždění event loopu

promql
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}

Pokud překročí 100 ms, váš event loop je blokovaný. Pravděpodobně synchronní operace v asynchronní cestě.

5. Využití haldy

promql
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100

Sledujte stabilní vzestupný trend — to je memory leak. Špičky během GC jsou normální.

6. Aktivní handles

promql
nodeapp_nodejs_active_handles_total

Otevřené souborové deskriptory, sokety, časovače. Neustále rostoucí číslo znamená, že „teče" handles — pravděpodobně neuzavíráte databázová připojení nebo HTTP odpovědi.

Dashboard jako kód v Grafaně#

Můžete verzovat své dashboardy pomocí funkce provisioningu Grafany:

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

Exportujte JSON dashboardu z Grafany, commitněte ho do vašeho repozitáře a váš dashboard přežije reinstalace Grafany. Tohle není volitelné pro produkci — je to stejný princip jako infrastruktura jako kód.

Distribuovaný tracing s OpenTelemetry#

Tracing je pilíř, který většina týmů adoptuje jako poslední, a ten, o kterém si přejí, aby ho adoptovali jako první. Když máte více služeb komunikujících spolu (i kdyby to bylo jen „API server + databáze + Redis + externí API"), tracing vám ukáže úplný obraz cesty požadavku.

Co je trace?#

Trace je strom spanů. Každý span reprezentuje jednotku práce — HTTP požadavek, databázový dotaz, volání funkce. Spany mají čas začátku, čas konce, stav a atributy. Jsou propojeny trace ID, které se propaguje přes hranice služeb.

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)

Jeden pohled vám řekne: 250ms požadavek strávil 180 ms čekáním na Stripe. Tam optimalizujete.

Nastavení OpenTelemetry#

OpenTelemetry (OTel) je standard. Nahradil fragmentovanou krajinu Jaeger klientů, Zipkin klientů a vendor-specifických SDK jedním, vendor-neutrálním API.

typescript
// src/instrumentation.ts
// This file MUST be loaded before any other imports.
// In Node.js, use --require or --import flag.
 
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",
  }),
 
  // Send traces to your collector (Jaeger, Tempo, etc.)
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
  }),
 
  // Optionally send metrics through OTel too
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
    }),
    exportIntervalMillis: 15000,
  }),
 
  // Auto-instrumentation: automatically creates spans for
  // HTTP requests, Express routes, PostgreSQL queries, Redis commands,
  // DNS lookups, and more
  instrumentations: [
    getNodeAutoInstrumentations({
      // Disable noisy instrumentations
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
      // Configure specific ones
      "@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)
  );
});

Spusťte svou aplikaci s:

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

To je vše. Bez jakýchkoli změn v kódu vaší aplikace nyní máte traces pro každý HTTP požadavek, každý databázový dotaz, každý Redis příkaz.

Ruční vytváření spanů#

Auto-instrumentace pokrývá infrastrukturní volání, ale někdy chcete trasovat business logiku:

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);
 
      // This span becomes the parent of any auto-instrumented
      // DB queries or HTTP calls inside these functions
      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();
    }
  });
}

Propagace kontextu trace#

Kouzlo distribuovaného tracingu je, že trace ID sleduje požadavek přes služby. Když Služba A volá Službu B, kontext trace je automaticky injektován do HTTP hlaviček (hlavička traceparent dle standardu W3C Trace Context).

Auto-instrumentace to zpracuje pro odchozí HTTP volání. Ale pokud používáte frontu zpráv, musíte propagovat ručně:

typescript
import { context, propagation } from "@opentelemetry/api";
 
// When publishing a message
function publishEvent(queue: string, payload: object) {
  const carrier: Record<string, string> = {};
 
  // Inject current trace context into the carrier
  propagation.inject(context.active(), carrier);
 
  // Send both the payload and the trace context
  messageQueue.publish(queue, {
    payload,
    traceContext: carrier,
  });
}
 
// When consuming a message
function consumeEvent(message: QueueMessage) {
  // Extract the trace context from the message
  const parentContext = propagation.extract(
    context.active(),
    message.traceContext
  );
 
  // Run the handler within the extracted context
  // Now any spans created here will be children of the original trace
  context.with(parentContext, () => {
    tracer.startActiveSpan("processEvent", (span) => {
      span.setAttribute("queue.message_id", message.id);
      handleEvent(message.payload);
      span.end();
    });
  });
}

Kam odesílat traces#

Jaeger — Klasická open-source možnost. Dobré UI, snadné spuštění lokálně s Dockerem. Omezené dlouhodobé úložiště.

Grafana Tempo — Pokud už používáte Grafanu a Loki, Tempo je přirozená volba pro traces. Používá objektové úložiště (S3/GCS) pro nákladově efektivní dlouhodobou retenci.

Grafana Cloud / Datadog / Honeycomb — Pokud nechcete provozovat infrastrukturu. Dražší, menší provozní režie.

Pro lokální vývoj je Jaeger v Dockeru ideální:

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

Endpointy health checků#

Health checky jsou nejjednodušší forma observability a první věc, kterou byste měli implementovat. Odpovídají na jednu otázku: „Je tato služba schopná obsluhovat požadavky právě teď?"

Tři typy health checků#

/health — Obecný stav. Běží proces a reaguje?

/ready — Připravenost. Může tato služba zpracovávat provoz? (Připojila se k databázi? Načetla konfiguraci? Zahřála cache?)

/live — Životaschopnost. Je proces živý a není v deadlocku? (Dokáže odpovědět na jednoduchý požadavek v rámci timeoutu?)

Rozlišení je důležité pro Kubernetes, kde liveness proby restartují zaseknuté kontejnery a readiness proby odebírají kontejnery z load balanceru během startu nebo selhání závislostí.

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 — just check if the process can respond
  router.get("/live", (_req, res) => {
    res.status(200).json({ status: "ok" });
  });
 
  // Readiness — check all dependencies
  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,
      },
    });
  });
 
  // Full health — detailed status for dashboards and 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",
    };
 
    // Return 200 for ok/degraded (service can still handle some traffic)
    // Return 503 for error (service should be removed from rotation)
    res.status(result.status === "error" ? 503 : 200).json(result);
  });
 
  return router;
}

Konfigurace Kubernetes prob#

yaml
# k8s/deployment.yml
spec:
  containers:
    - name: api
      livenessProbe:
        httpGet:
          path: /live
          port: 3000
        initialDelaySeconds: 10
        periodSeconds: 15
        timeoutSeconds: 5
        failureThreshold: 3    # Restart after 3 consecutive failures (45s)
      readinessProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 5
        periodSeconds: 10
        timeoutSeconds: 5
        failureThreshold: 2    # Remove from LB after 2 failures (20s)
      startupProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 0
        periodSeconds: 5
        failureThreshold: 30   # Give up to 150s for startup

Častá chyba: příliš agresivní liveness proba. Pokud vaše liveness proba kontroluje databázi a databáze je dočasně nedostupná, Kubernetes restartuje váš kontejner. Ale restart neopraví databázi. Teď máte crash loop na vrcholu výpadku databáze. Udržujte liveness proby jednoduché — měly by detekovat pouze zablokované nebo zaseknuté procesy.

Sledování chyb se Sentry#

Logy zachytí chyby, které jste očekávali. Sentry zachytí ty, které jste neočekávali.

Rozdíl je důležitý. Přidáváte bloky try/catch kolem kódu, o kterém víte, že by mohl selhat. Ale chyby, na kterých nejvíce záleží, jsou v kódu, o kterém jste si mysleli, že je bezpečný. Neošetřené odmítnuté promisy, typové chyby z neočekávaných odpovědí API, přístup k null na optional chainech, které nebyly dostatečně optional.

Nastavení Sentry pro 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% of transactions for performance monitoring
    // (100% in development)
    tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
 
    // Profile 100% of sampled transactions
    profilesSampleRate: 1.0,
 
    integrations: [
      nodeProfilingIntegration(),
      // Filter out noisy errors
      Sentry.rewriteFramesIntegration({
        root: process.cwd(),
      }),
    ],
 
    // Don't send errors from development
    enabled: process.env.NODE_ENV === "production",
 
    // Filter out known non-issues
    ignoreErrors: [
      // Client disconnects aren't bugs
      "ECONNRESET",
      "ECONNABORTED",
      "EPIPE",
      // Bots sending garbage
      "SyntaxError: Unexpected token",
    ],
 
    // Strip PII before sending
    beforeSend(event) {
      // Remove IP addresses
      if (event.request) {
        delete event.request.headers?.["x-forwarded-for"];
        delete event.request.headers?.["x-real-ip"];
        delete event.request.cookies;
      }
 
      // Remove sensitive query params
      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 se 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 must come first
export const sentryRequestHandler = Sentry.Handlers.requestHandler();
 
// Sentry tracing handler
export const sentryTracingHandler = Sentry.Handlers.tracingHandler();
 
// Your custom error handler comes last
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  const log = getLogger();
 
  // Add custom context to the 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,
        // Don't send email or username to Sentry
      });
    }
 
    // Add breadcrumbs for debugging
    scope.addBreadcrumb({
      category: "request",
      message: `${req.method} ${req.path}`,
      level: "info",
      data: {
        query: req.query,
        statusCode: res.statusCode,
      },
    });
 
    Sentry.captureException(err);
  });
 
  // Log the error with full context
  log.error(
    {
      err,
      statusCode: 500,
      route: req.route?.path || req.path,
      method: req.method,
    },
    "Unhandled error in request handler"
  );
 
  // Send a generic error response
  // Never expose error details to the client in production
  res.status(500).json({
    error: "Internal Server Error",
    ...(process.env.NODE_ENV !== "production" && {
      message: err.message,
      stack: err.stack,
    }),
  });
}

Source mapy#

Bez source map vám Sentry ukáže minifikované/transpilované stack traces. K ničemu. Nahrajte source mapy během buildu:

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

Nebo to nakonfigurujte ve svém bundleru:

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

Cena neošetřených odmítnutí promisů#

Od Node.js 15 neošetřená odmítnutí promisů ve výchozím nastavení ukončí proces. To je dobré — nutí vás ošetřovat chyby. Ale potřebujete záchrannou síť:

typescript
// src/server.ts — near the top of your entry point
 
process.on("unhandledRejection", (reason, promise) => {
  logger.fatal({ reason, promise }, "Unhandled promise rejection — crashing");
  Sentry.captureException(reason);
 
  // Flush Sentry events before crashing
  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);
  });
});

Důležitá část: Sentry.flush() před process.exit(). Bez toho se událost chyby nemusí dostat do Sentry, než proces zemře.

Alerting: Alerty, na kterých skutečně záleží#

Mít 200 metrik v Prometheus a nula alertů je jen marnivý monitoring. Mít 50 alertů, které se spouštějí každý den, je alertová únava — začnete je ignorovat, a pak vám unikne ten, na kterém záleží.

Cílem je malý počet vysoce signálních alertů, které znamenají „něco je skutečně špatně a člověk se na to musí podívat."

Konfigurace Prometheus AlertManager#

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" . }}'

Alerty, které mě skutečně vzbudí#

yaml
# prometheus/rules/node-api.yml
groups:
  - name: node-api-critical
    rules:
      # High error rate — something is broken
      - 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"
 
      # Slow responses — users are suffering
      - 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 }}"
 
      # Memory leak — will OOM soon
      - 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 }}"
 
      # Process 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 is getting slow
      - 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 }}"
 
      # Traffic dropped significantly — possible routing issue
      - 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"
 
      # Database queries getting slow
      - 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 }}"
 
      # External API is failing
      - 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%"

Všimněte si klauzule for u každého alertu. Bez ní by jediná špička spustila alert. 5minutové for znamená, že podmínka musí platit 5 souvislých minut. To eliminuje šum z momentálních výkyvů.

Hygiena alertů#

Každý alert musí projít tímto testem:

  1. Je proveditelný? Pokud s tím nikdo nemůže nic udělat, nealertujte. Zalogujte to, dejte to na dashboard, ale nebuďte nikoho.
  2. Vyžaduje lidský zásah? Pokud se to samo vyřeší (jako krátký síťový výkyv), klauzule for by to měla odfiltrovat.
  3. Spustil se za posledních 30 dní? Pokud ne, může být špatně nakonfigurovaný nebo je práh špatný. Zkontrolujte ho.
  4. Když se spustí, záleží na tom lidem? Pokud ho tým pravidelně odmítá, odstraňte ho nebo upravte práh.

Své alerty audituju čtvrtletně. Každý alert dostane jeden ze tří výsledků: ponechat, upravit práh, nebo smazat.

Složení dohromady: Express aplikace#

Zde je, jak všechny části do sebe zapadají v reálné aplikaci:

typescript
// src/server.ts
import { initSentry } from "./lib/sentry";
 
// Initialize Sentry first — before other 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);
 
// --- Middleware Order Matters ---
 
// 1. Sentry request handler (must be first)
app.use(sentryRequestHandler);
app.use(sentryTracingHandler);
 
// 2. Async context (creates request-scoped context)
app.use(asyncContextMiddleware);
 
// 3. Request logging
app.use(requestLogger);
 
// 4. Metrics collection
app.use(metricsMiddleware);
 
// 5. Body parsing
app.use(express.json({ limit: "1mb" }));
 
// --- Routes ---
 
// Health checks (no auth required)
app.use(createHealthRoutes(pool, redis));
 
// Metrics (basic auth protected)
app.use(metricsRouter);
 
// API routes
app.use("/api", apiRouter);
 
// --- Error Handling ---
 
// Sentry error handler (must be before custom error handler)
app.use(Sentry.Handlers.errorHandler());
 
// Custom error handler (must be last)
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"
  );
});
 
// Graceful shutdown
async function shutdown(signal: string) {
  logger.info({ signal }, "Shutdown signal received");
 
  // Stop accepting new connections
  // Process in-flight requests (Express does this automatically)
 
  // Close database pool
  await pool.end().catch((err) => {
    logger.error({ err }, "Error closing database pool");
  });
 
  // Close Redis connection
  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"));

Minimální životaschopný stack#

Vše výše je „plný" stack. Nepotřebujete to všechno od prvního dne. Zde je návod, jak škálovat vaši observabilitu s růstem projektu.

Fáze 1: Vedlejší projekt / Sólový vývojář#

Potřebujete tři věci:

  1. Strukturované konzolové logy — Použijte Pino, výstup JSON na stdout. I když je jen čtete s pm2 logs, JSON logy jsou prohledávatelné a parsovatelné.

  2. Endpoint /health — Implementace zabere 5 minut, zachrání vás, když ladíte „vůbec to běží?"

  3. Sentry free tier — Zachytí chyby, které jste neočekávali. Free tier vám dává 5 000 událostí/měsíc, což je pro vedlejší projekt dostatečné.

typescript
// This is the minimal setup. Under 50 lines. No 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"));

Fáze 2: Rostoucí projekt / Malý tým#

Přidejte:

  1. Prometheus metriky + Grafana — Když „zdá se to pomalé" nestačí a potřebujete data. Začněte s mírou požadavků, mírou chyb a p99 latencí.

  2. Agregace logů — Když ssh na server a grepování přes soubory přestane stačit. Loki + Promtail, pokud už používáte Grafanu.

  3. Základní alerty — Míra chyb > 1 %, p99 > 1s, služba nedostupná. Tři alerty. To je vše.

Fáze 3: Produkční služba / Více služeb#

Přidejte:

  1. Distribuovaný tracing s OpenTelemetry — Když „API je pomalé" se změní na „která z 5 služeb, které volá, je pomalá?" Auto-instrumentace OTel vám dá 80 % hodnoty bez jakýchkoli změn kódu.

  2. Dashboard jako kód — Verzujte své Grafana dashboardy. Poděkujete si, až je budete muset znovu vytvořit.

  3. Strukturovaný alerting — AlertManager se správným směrováním, eskalací a pravidly ztlumení.

  4. Business metriky — Objednávky/sekundu, konverzní poměr, hloubka fronty. Metriky, které zajímají váš produktový tým.

Co vynechat#

  • APM vendoři s cenou za host — Ve větším měřítku je cena absurdní. Open source (Prometheus + Grafana + Tempo + Loki) vám dá 95 % funkcionality.
  • Úrovně logů pod INFO v produkci — Vygenerujete terabajty DEBUG logů a zaplatíte za úložiště. DEBUG používejte jen při aktivním vyšetřování problémů, pak ho vypněte.
  • Vlastní metriky pro všechno — Začněte s metodou RED (Rate, Errors, Duration) pro každou službu. Vlastní metriky přidávejte, jen když máte konkrétní otázku k zodpovězení.
  • Složité vzorkování traces — Začněte s jednoduchým vzorkovacím poměrem (10 % v produkci). Adaptivní vzorkování je předčasná optimalizace pro většinu týmů.

Závěrečné myšlenky#

Observabilita není produkt, který si koupíte, nebo nástroj, který nainstalujete. Je to praxe. Je to rozdíl mezi provozováním vaší služby a doufáním, že se vaše služba provozuje sama.

Stack, který jsem popsal — Pino pro logy, Prometheus pro metriky, OpenTelemetry pro traces, Sentry pro chyby, Grafana pro vizualizaci, AlertManager pro alerty — není nejjednodušší možný setup. Ale každý kousek si zaslouží své místo tím, že odpovídá na otázku, kterou ostatní kousky nemohou.

Začněte se strukturovanými logy a health endpointem. Přidejte metriky, když potřebujete vědět „jak moc to je." Přidejte traces, když potřebujete vědět „kde se ztrácí čas." Každá vrstva staví na předchozí a žádná z nich nevyžaduje přepsání vaší aplikace.

Nejlepší čas přidat observabilitu byl před vaším posledním produkčním incidentem. Druhý nejlepší čas je teď.

Související články