Перейти до вмісту
·30 хв читання

Observability у Node.js: Логи, метрики та трейси без складності

Структуроване логування з Pino, метрики з Prometheus, розподілений трейсинг з OpenTelemetry. Стек observability для продакшн Node.js-застосунків, від нуля до повної видимості.

Поділитися:X / TwitterLinkedIn

Раніше я думав, що observability означає "додай кілька console.log і перевір їх, коли щось зламається." Це працювало, поки не перестало. Переломний момент настав під час продакшн-інциденту, коли наш API повертав 200-ки, але дані були застарілими. Жодних помилок у логах. Жодних виключень. Просто тихо некоректні відповіді, бо downstream-кеш застарів і ніхто не помічав цього чотири години.

Тоді я зрозумів різницю між моніторингом та observability. Моніторинг каже тобі, що щось не так. Observability каже тобі, чому не так. І прогалина між цими двома — це те місце, де живуть продакшн-інциденти.

Це стек observability, на якому я зупинився для Node.js-сервісів, спробувавши більшість альтернатив. Це не найскладніше налаштування у світі, але воно ловить проблеми до того, як користувачі їх помітять, а коли щось все ж проскакує — я можу діагностувати за хвилини замість годин.

Три стовпи, і чому тобі потрібні всі три#

Усі говорять про "три стовпи observability" — логи, метрики та трейси. Чого ніхто не каже — це що кожен стовп відповідає на принципово інше питання, і тобі потрібні всі три, бо жоден окремий стовп не може відповісти на кожне питання.

Логи відповідають: Що сталося?

Рядок логу каже "о 14:23:07 користувач 4821 запитав /api/orders і отримав 500, бо з'єднання з базою даних вичерпало таймаут." Це наратив. Він розповідає історію однієї конкретної події.

Метрики відповідають: Скільки відбувається?

Метрика каже "за останні 5 хвилин p99 час відповіді становив 2.3 секунди, а рейт помилок — 4.7%." Це агреговані дані. Вони розповідають про здоров'я системи в цілому, а не про будь-який індивідуальний запит.

Трейси відповідають: Куди подівся час?

Трейс каже "цей запит витратив 12 мс у Express middleware, 3 мс на парсинг тіла, 847 мс на очікування PostgreSQL і 2 мс на серіалізацію відповіді." Це водоспад. Він каже тобі точно, де пляшкове горлечко, через межі сервісів.

Ось практичний наслідок: коли твій пейджер спрацьовує о 3 ночі, послідовність майже завжди однакова.

  1. Метрики кажуть тобі, що щось не так (спайк рейту помилок, збільшення затримки)
  2. Логи кажуть, що відбувається (конкретні повідомлення помилок, уражені ендпоінти)
  3. Трейси кажуть чому (який downstream-сервіс або запит до бази є пляшковим горлечком)

Якщо у тебе тільки логи, ти знатимеш що зламалось, але не наскільки погано. Якщо тільки метрики — знатимеш наскільки погано, але не що це спричиняє. Якщо тільки трейси — матимеш гарні водоспади, але не знатимеш, коли на них дивитися.

Давай побудуємо кожен.

Структуроване логування з Pino#

Чому console.log недостатньо#

Я знаю. Ти використовуєш console.log у продакшені і це "нормально." Дай покажу, чому це не так.

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.

Структуроване логування означає, що кожен запис логу — це JSON-об'єкт з консистентними полями. Замість людино-читабельного рядка, ворожого для машин, ти отримуєш машино-читабельний об'єкт, який також людино-читабельний (з правильними інструментами).

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
}

Тепер ти можеш робити запити. level >= 50 AND msg = "User login failed" AND time > now() - 1h дає тобі саме те, що потрібно.

Pino проти Winston#

Я використовував обидва широко. Ось коротка версія:

Winston популярніший, гнучкіший, має більше транспортів і значно повільніший. Він також заохочує погані патерни — система "format" робить занадто легким створення неструктурованих, pretty-printed логів, які гарно виглядають у розробці, але непарсабельні в продакшені.

Pino швидший (у 5-10 разів у бенчмарках), категоричний щодо JSON-виводу і слідує Unix-філософії: роби одну річ добре (пиши JSON у stdout) і дай іншим інструментам обробляти решту (транспорт, форматування, агрегація).

Я використовую Pino. Різниця в продуктивності має значення, коли ти логуєш тисячі запитів на секунду, а категоричний підхід означає, що кожен розробник у команді виробляє консистентні логи.

Базове налаштування 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]",
  },
});

Опція redact критична. Без неї ти рано чи пізно залогуєш пароль або API-ключ. Це не питання "чи", а "коли." Якийсь розробник додасть logger.info({ body: req.body }, "incoming request") і раптом ти логуєш номери кредитних карток. Редакція — твоя сітка безпеки.

Рівні логів: Використовуй їх правильно#

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");

Правило: якщо сумніваєшся між INFO та DEBUG — це DEBUG. Якщо сумніваєшся між WARN та ERROR — запитай себе: "Чи хочу я, щоб мене алертнуло про це о 3 ночі?" Якщо так — ERROR. Якщо ні — WARN.

Дочірні логери та контекст запиту#

Ось де Pino реально блищить. Дочірній логер наслідує всю конфігурацію батьківського, але додає додаткові контекстні поля.

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

Для HTTP-серверів ти хочеш дочірній логер на кожен запит, щоб кожен рядок логу в життєвому циклі запиту включав request 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();
 
  // 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 для автоматичного поширення контексту#

Підхід з дочірнім логером працює, але вимагає передавати req.log через кожен виклик функції. Це стає нудним. AsyncLocalStorage вирішує це — він надає сховище контексту, що слідує за асинхронним потоком виконання без явної передачі.

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();
  });
}

Тепер будь-яка функція, будь-де в стеку викликів, може отримати логер зі скоупом запиту:

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.

Агрегація логів: Куди йдуть логи?#

У розробці логи йдуть у stdout, а pino-pretty робить їх читабельними. У продакшені все складніше.

Шлях PM2#

Якщо ти працюєш на VPS з PM2 (про що я писав у пості про налаштування VPS), PM2 автоматично перехоплює 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)

Вбудована ротація логів PM2 запобігає проблемам з дисковим простором:

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

Відправка логів до Loki або Elasticsearch#

Для чого-небудь більшого за один сервер тобі потрібна централізована агрегація логів. Два основні варіанти:

Grafana Loki — "Prometheus для логів." Легкий, індексує тільки лейбли (не повний текст), чудово працює з Grafana. Моя рекомендація для більшості команд.

Elasticsearch — Повнотекстовий пошук по логах. Потужніший, більш ресурсомісткий, більше операційних витрат. Використовуй це, якщо тобі реально потрібен повнотекстовий пошук по мільйонах рядків логів.

Для Loki найпростіше налаштування використовує Promtail для відправки логів:

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#

Pino за замовчуванням виводить Newline Delimited JSON (NDJSON) — один JSON-об'єкт на рядок, розділені \n. Це важливо, бо:

  1. Кожен інструмент агрегації логів розуміє це
  2. Це стрімабельне (ти можеш обробляти логи рядок за рядком без буферизації всього файлу)
  3. Стандартні Unix-інструменти працюють з ним: cat api-out.log | jq '.msg' | sort | uniq -c | sort -rn

Ніколи не налаштовуй Pino виводити pretty-printed, багаторядковий JSON у продакшені. Ти зламаєш кожен інструмент у пайплайні.

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

Метрики з Prometheus#

Логи кажуть тобі, що сталося. Метрики кажуть, як система працює. Різниця — як між читанням кожної транзакції у виписці з банку та поглядом на баланс рахунку.

Чотири типи метрик#

Prometheus має чотири типи метрик. Розуміння, який використовувати коли, врятує тебе від найпоширеніших помилок.

Counter — Значення, що тільки зростає. Кількість запитів, кількість помилок, оброблені байти. Скидається в нуль при перезапуску.

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 — Значення, що може зростати і зменшуватись. Поточні з'єднання, розмір черги, температура, використання купи.

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

Histogram — Спостерігає значення та рахує їх у налаштовуваних бакетах. Тривалість запиту, розмір відповіді. Так ти отримуєш перцентилі (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 — Схожий на Histogram, але обчислює квантилі на стороні клієнта. Використовуй Histogram, якщо немає конкретної причини не робити цього. Summary не можна агрегувати між інстансами.

Повне налаштування 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 метрик#

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();
}

Ендпоінт /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;

Кастомні бізнес-метрики — справжня сила#

Дефолтні метрики Node.js (розмір купи, лаг event loop, тривалість GC) — це базовий мінімум. Вони кажуть про здоров'я рантайму. Але бізнес-метрики кажуть про здоров'я застосунку.

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;
  }
}

Спайк ordersProcessed{status="failed"} скаже тобі те, чого жодна кількість CPU-метрик ніколи не скаже.

Кардинальність лейблів: Тихий вбивця#

Слово застереження. Кожна унікальна комбінація значень лейблів створює нову часову серію. Якщо ти додаш лейбл userId до свого лічильника HTTP-запитів, і у тебе 100 000 користувачів — ти щойно створив 100 000+ часових серій. Prometheus зупиниться.

Правила для лейблів:

  • Тільки низька кардинальність: HTTP-метод (7 значень), код статусу (5 категорій), маршрут (десятки, не тисячі)
  • Ніколи не використовуй ID користувачів, ID запитів, IP-адреси або таймстемпи як значення лейблів
  • Якщо не впевнений — не додавай лейбл. Ти завжди можеш додати його пізніше, але видалення вимагає зміни дашбордів та алертів

Дашборди Grafana#

Prometheus зберігає дані. Grafana візуалізує їх. Ось панелі, які я ставлю на кожен дашборд Node.js-сервісу.

Основний дашборд#

1. Рейт запитів (запитів/секунду)

promql
rate(nodeapp_http_requests_total[5m])

Показує патерн трафіку. Корисно для виявлення раптових спайків або падінь.

2. Рейт помилок (%)

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

Найважливіше число. Якщо перевищує 1% — щось не так.

3. p50 / p95 / p99 затримка

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

p50 каже тобі типовий досвід. p99 каже найгірший досвід. Якщо p99 у 10 разів більше p50 — у тебе проблема хвостової затримки.

4. Лаг Event Loop

promql
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}

Якщо перевищує 100 мс — твій event loop заблокований. Ймовірно, синхронна операція в асинхронному шляху.

5. Використання купи

promql
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100

Стеж за стабільним висхідним трендом — це витік пам'яті. Спайки під час GC — це нормально.

6. Активні хендли

promql
nodeapp_nodejs_active_handles_total

Відкриті файлові дескриптори, сокети, таймери. Безперервно зростаюче число означає витік хендлів — ймовірно, ти не закриваєш з'єднання з базою або HTTP-відповіді.

Дашборд Grafana як код#

Ти можеш версіонувати свої дашборди, використовуючи фічу provisioning Grafana:

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

Експортуй JSON свого дашборду з Grafana, закоміть до репозиторію — і твій дашборд переживе перевстановлення Grafana. Це не опціонально для продакшену — це той самий принцип, що й інфраструктура як код.

Розподілений трейсинг з OpenTelemetry#

Трейсинг — це стовп, який більшість команд впроваджує останнім, і той, який вони хотіли б впровадити першим. Коли у тебе кілька сервісів, що спілкуються один з одним (навіть якщо це просто "API-сервер + база даних + Redis + зовнішній API"), трейсинг показує повну картину подорожі запиту.

Що таке трейс?#

Трейс — це дерево спанів. Кожен спан представляє одиницю роботи — HTTP-запит, запит до бази даних, виклик функції. Спани мають час початку, час завершення, статус та атрибути. Вони пов'язані trace ID, що поширюється через межі сервісів.

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)

Один погляд каже тобі: запит за 250 мс витратив 180 мс на очікування Stripe. Ось де оптимізувати.

Налаштування OpenTelemetry#

OpenTelemetry (OTel) — це стандарт. Він замінив фрагментований ландшафт клієнтів Jaeger, клієнтів Zipkin та вендор-специфічних SDK єдиним, вендор-нейтральним 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)
  );
});

Запускай свій застосунок так:

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

Все. Без жодних змін у коді застосунку ти тепер отримуєш трейси для кожного HTTP-запиту, кожного запиту до бази, кожної Redis-команди.

Ручне створення спанів#

Автоінструментація покриває інфраструктурні виклики, але іноді ти хочеш трейсити бізнес-логіку:

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();
    }
  });
}

Поширення контексту трейсу#

Магія розподіленого трейсингу в тому, що trace ID слідує за запитом через сервіси. Коли Сервіс A викликає Сервіс B, контекст трейсу автоматично інжектується в HTTP-заголовки (заголовок traceparent за стандартом W3C Trace Context).

Автоінструментація обробляє це для вихідних HTTP-викликів. Але якщо ти використовуєш чергу повідомлень, потрібно поширювати вручну:

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();
    });
  });
}

Куди відправляти трейси#

Jaeger — Класичний опенсорсний варіант. Хороший UI, легко запустити локально з Docker. Обмежене довгострокове зберігання.

Grafana Tempo — Якщо ти вже використовуєш Grafana та Loki, Tempo — природний вибір для трейсів. Використовує об'єктне сховище (S3/GCS) для економного довгострокового зберігання.

Grafana Cloud / Datadog / Honeycomb — Якщо ти не хочеш піднімати інфраструктуру. Дорожче, менше операційних витрат.

Для локальної розробки Jaeger у Docker ідеальний:

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

Ендпоінти перевірки здоров'я#

Перевірки здоров'я — це найпростіша форма observability і перше, що ти маєш реалізувати. Вони відповідають на одне питання: "Чи здатний цей сервіс обслуговувати запити прямо зараз?"

Три типи перевірок здоров'я#

/health — Загальне здоров'я. Чи процес працює і відповідає?

/ready — Готовність. Чи може цей сервіс обробляти трафік? (Чи підключився він до бази даних? Чи завантажив конфігурацію? Чи прогрів кеш?)

/live — Живість. Чи процес живий і не в дедлоку? (Чи може він відповісти на простий запит у межах таймауту?)

Розрізнення має значення для Kubernetes, де liveness probes перезапускають завислі контейнери, а readiness probes видаляють контейнери з балансувальника навантаження під час запуску або відмов залежностей.

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;
}

Конфігурація Kubernetes Probe#

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

Поширена помилка: робити liveness probe занадто агресивним. Якщо твій liveness probe перевіряє базу даних, а база тимчасово недоступна, Kubernetes перезапустить контейнер. Але перезапуск не виправить базу даних. Тепер у тебе crash loop поверх відмови бази. Тримай liveness probes простими — вони повинні визначати лише дедлокнуті або завислі процеси.

Відстеження помилок з Sentry#

Логи ловлять помилки, які ти очікував. Sentry ловить ті, яких не очікував.

Різниця важлива. Ти додаєш try/catch-блоки навколо коду, який, як ти знаєш, може впасти. Але баги, які найбільш важливі — це ті в коді, який ти вважав безпечним. Необроблені rejections промісів, type errors від неочікуваних відповідей API, доступ до null через optional chains, які виявились недостатньо optional.

Налаштування Sentry для 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 з 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 Maps#

Без source maps Sentry показує тобі мініфіковані/транспільовані стек-трейси. Марні. Вивантажуй source maps під час білду:

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

Або налаштуй у своєму бандлері:

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,
    }),
  ],
});

Ціна необроблених Promise Rejections#

Починаючи з Node.js 15, необроблені promise rejections крашать процес за замовчуванням. Це добре — змушує тебе обробляти помилки. Але тобі потрібна сітка безпеки:

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);
  });
});

Важлива частина: Sentry.flush() перед process.exit(). Без цього подія помилки може не дійти до Sentry до того, як процес помре.

Алертинг: Алерти, що реально мають значення#

Мати 200 метрик Prometheus і нуль алертів — це тщеславний моніторинг. Мати 50 алертів, що спрацьовують щодня — це alert fatigue, і ти почнеш їх ігнорувати, а потім пропустиш той, що мав значення.

Мета — невелика кількість високосигнальних алертів, що означають "щось реально не так і людина має поглянути на це."

Конфігурація 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" . }}'

Алерти, що реально будять мене#

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%"

Зверни увагу на клаузу for у кожному алерті. Без неї один спайк тригерить алерт. 5-хвилинний for означає, що умова має бути істинною 5 безперервних хвилин. Це усуває шум від моментних сплесків.

Гігієна алертів#

Кожен алерт повинен пройти цей тест:

  1. Він дієвий? Якщо ніхто не може нічого зробити — не алертуй. Логуй, дашбордуй, але не буди нікого.
  2. Він вимагає людського втручання? Якщо самозцілюється (як короткий мережевий блімп), клауза for має відфільтрувати це.
  3. Чи спрацьовував він за останні 30 днів? Якщо ні — можливо, неправильно налаштований або поріг невірний. Переглянь.
  4. Коли спрацьовує — чи людям не байдуже? Якщо команда регулярно його ігнорує — видали або виправ поріг.

Я аудитую свої алерти щоквартально. Кожен алерт отримує один з трьох результатів: залишити, підправити поріг або видалити.

Збираємо все разом: Express-застосунок#

Ось як всі частини з'єднуються в реальному застосунку:

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"));

Мінімально життєздатний стек#

Все вищеописане — це "повний" стек. Тобі не потрібне все це з першого дня. Ось як масштабувати свою observability в міру зростання проєкту.

Етап 1: Побічний проєкт / Соло-розробник#

Тобі потрібні три речі:

  1. Структуровані консольні логи — Використовуй Pino, виводь JSON у stdout. Навіть якщо ти просто читаєш їх через pm2 logs, JSON-логи піддаються пошуку та парсингу.

  2. Ендпоінт /health — Займає 5 хвилин на реалізацію, рятує, коли дебажиш "а воно взагалі працює?"

  3. Безкоштовний тариф Sentry — Ловить помилки, яких ти не передбачив. Безкоштовний тариф дає 5 000 подій/місяць, що вистачає для побічного проєкту.

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"));

Етап 2: Зростаючий проєкт / Маленька команда#

Додай:

  1. Метрики Prometheus + Grafana — Коли "відчувається повільним" недостатньо і потрібні дані. Почни з рейту запитів, рейту помилок та p99 затримки.

  2. Агрегація логів — Коли ssh на сервер і grepання по файлах перестає масштабуватися. Loki + Promtail, якщо вже використовуєш Grafana.

  3. Базові алерти — Рейт помилок > 1%, p99 > 1 с, сервіс не працює. Три алерти. Все.

Етап 3: Продакшн-сервіс / Кілька сервісів#

Додай:

  1. Розподілений трейсинг з OpenTelemetry — Коли "API повільний" перетворюється на "який з 5 сервісів, які він викликає, повільний?" Автоінструментація OTel дає 80% цінності без жодних змін у коді.

  2. Дашборд як код — Версіонуй свої дашборди Grafana. Ти подякуєш собі, коли потрібно буде відтворити їх.

  3. Структурований алертинг — AlertManager з правильною маршрутизацією, ескалацією та правилами тишини.

  4. Бізнес-метрики — Замовлення/секунду, конверсія, глибина черги. Метрики, які цікавлять твою продуктову команду.

Що пропустити#

  • APM-вендори з ціною за хост — У масштабі вартість абсурдна. Опенсорс (Prometheus + Grafana + Tempo + Loki) дає 95% функціональності.
  • Рівні логів нижче INFO у продакшені — Ти генеруватимеш терабайти DEBUG-логів і платитимеш за зберігання. Використовуй DEBUG тільки під час активного розслідування, потім вимикай.
  • Кастомні метрики для всього — Почни з методу RED (Rate, Errors, Duration) для кожного сервісу. Додавай кастомні метрики тільки коли маєш конкретне питання для відповіді.
  • Складний семплінг трейсів — Почни з простого рейту семплінгу (10% у продакшені). Адаптивний семплінг — це передчасна оптимізація для більшості команд.

Фінальні думки#

Observability — це не продукт, який купуєш, і не інструмент, який встановлюєш. Це практика. Це різниця між тим, щоб оперувати своїм сервісом, і тим, щоб сподіватися, що він оперує себе сам.

Стек, який я описав — Pino для логів, Prometheus для метрик, OpenTelemetry для трейсів, Sentry для помилок, Grafana для візуалізації, AlertManager для алертів — не найпростіше можливе налаштування. Але кожна частина заслуговує своє місце, відповідаючи на питання, на яке інші частини не можуть відповісти.

Починай зі структурованих логів та ендпоінту здоров'я. Додавай метрики, коли потрібно знати "наскільки все погано." Додавай трейси, коли потрібно знати "куди подівся час." Кожен шар будується на попередньому, і жоден не вимагає переписування застосунку.

Найкращий час додати observability був перед твоїм останнім продакшн-інцидентом. Другий найкращий час — зараз.

Схожі записи