Перейти к содержимому
·29 мин чтения

Наблюдаемость в Node.js: логи, метрики и трейсы без лишней сложности

Структурированное логирование с Pino, метрики с Prometheus, распределённая трассировка с OpenTelemetry. Стек наблюдаемости для продакшн Node.js-приложений от нуля до полной видимости.

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

Раньше я думал, что наблюдаемость означает «добавить несколько console.log и проверять их, когда что-то сломается». Это работало, пока не перестало. Переломным моментом стал продакшн-инцидент, когда наш API возвращал 200-е, но данные были устаревшими. Никаких ошибок в логах. Никаких исключений. Просто молча неправильные ответы, потому что нижестоящий кэш устарел, и никто не замечал это четыре часа.

Тогда я узнал разницу между мониторингом и наблюдаемостью. Мониторинг говорит тебе, что что-то не так. Наблюдаемость говорит, почему что-то не так. И разрыв между ними — это место, где живут продакшн-инциденты.

Это стек наблюдаемости, на котором я остановился для Node.js-сервисов после того, как попробовал большинство альтернатив. Это не самый продвинутый сетап в мире, но он ловит проблемы до того, как пользователи их заметят, а когда что-то всё же проскакивает, я могу диагностировать это за минуты, а не за часы.

Три столпа, и почему тебе нужны все три#

Все говорят о «трёх столпах наблюдаемости» — логи, метрики и трейсы. Чего никто не говорит — каждый столп отвечает на принципиально разный вопрос, и тебе нужны все три, потому что ни один столп не может ответить на каждый вопрос.

Логи отвечают: Что произошло?

Строка лога говорит: «в 14:23:07 пользователь 4821 запросил /api/orders и получил 500, потому что соединение с базой данных таймаутнулось». Это нарратив. Он рассказывает историю одного конкретного события.

Метрики отвечают: Сколько всего происходит?

Метрика говорит: «за последние 5 минут p99 время ответа было 2.3 секунды, а частота ошибок — 4.7%». Это агрегированные данные. Они рассказывают о здоровье системы в целом, а не об отдельном запросе.

Трейсы отвечают: Куда ушло время?

Трейс говорит: «этот запрос потратил 12мс в Express middleware, 3мс на парсинг тела, 847мс в ожидании PostgreSQL и 2мс на сериализацию ответа». Это водопад. Он точно показывает, где узкое место, пересекая границы сервисов.

Вот практическое следствие: когда тебя будят пейджером в 3 часа ночи, последовательность почти всегда одна и та же.

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

Если у тебя есть только логи, ты будешь знать, что сломалось, но не насколько плохо. Если только метрики — будешь знать, насколько плохо, но не что вызывает проблему. Если только трейсы — у тебя будут красивые водопады, но нет способа узнать, когда на них смотреть.

Давай построим каждый из них.

Структурированное логирование с Pino#

Почему console.log недостаточно#

Знаю. Ты используешь console.log в продакшене, и это «нормально». Давай покажу, почему нет.

typescript
// Что ты пишешь
console.log("User login failed", email, error.message);
 
// Что попадает в лог-файл
// User login failed john@example.com ECONNREFUSED
 
// Теперь попробуй:
// 1. Найти все неудачные логины за последний час
// 2. Подсчитать неудачи по пользователям
// 3. Отфильтровать только ошибки ECONNREFUSED
// 4. Соотнести это с запросом, который это вызвал
// Удачи. Это неструктурированная строка. Ты грепаешь по тексту.

Структурированное логирование означает, что каждая запись лога — это JSON-объект с консистентными полями. Вместо человекочитаемой строки, враждебной к машинной обработке, ты получаешь машиночитаемый объект, который также человекочитаем (с правильными инструментами).

typescript
// Как выглядит структурированное логирование
{
  "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 vs Winston#

Я интенсивно использовал оба. Вот короткая версия:

Winston более популярный, более гибкий, имеет больше транспортов и значительно медленнее. Он также поощряет плохие практики — система «format» слишком легко позволяет создавать неструктурированные, красиво отформатированные логи, которые выглядят хорошо в разработке, но непарсируемы в продакшене.

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"),
  // В продакшене — просто JSON в stdout. PM2/контейнерный рантайм обрабатывает остальное.
  // В разработке — pino-pretty для человекочитаемого вывода.
  ...(isProduction
    ? {}
    : {
        transport: {
          target: "pino-pretty",
          options: {
            colorize: true,
            translateTime: "HH:MM:ss",
            ignore: "pid,hostname",
          },
        },
      }),
  // Стандартные поля в каждой строке лога
  base: {
    service: process.env.SERVICE_NAME || "api",
    version: process.env.APP_VERSION || "unknown",
  },
  // Корректная сериализация объектов Error
  serializers: {
    err: pino.stdSerializers.err,
    error: pino.stdSerializers.err,
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
  },
  // Маскировка чувствительных полей
  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) — Процесс вот-вот упадёт. Будите кого-нибудь.
logger.fatal({ err }, "Unrecoverable database connection failure");
 
// ERROR (50) — Что-то сломалось, чего не должно было. Расследуйте в ближайшее время.
logger.error({ err, userId, orderId }, "Payment processing failed");
 
// WARN (40) — Что-то неожиданное, но обработанное. Следите за этим.
logger.warn({ retryCount: 3, service: "email" }, "Retry limit approaching");
 
// INFO (30) — Нормальные операции, которые стоит записать. Лог «что произошло».
logger.info({ userId, action: "login" }, "User authenticated");
 
// DEBUG (20) — Подробная информация для отладки. Никогда в продакшене.
logger.debug({ query, params }, "Database query executing");
 
// TRACE (10) — Крайне подробно. Только когда ты в отчаянии.
logger.trace({ headers: req.headers }, "Incoming request headers");

Правило: если ты колеблешься между INFO и DEBUG — это DEBUG. Если между WARN и ERROR — спроси себя: «Я бы хотел получить алерт об этом в 3 часа ночи?» Если да — ERROR. Если нет — WARN.

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

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

typescript
// Каждый лог от этого дочернего логгера будет включать userId и sessionId
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
 
userLogger.info("User viewed dashboard");
// Вывод автоматически включает userId и sessionId
 
userLogger.info({ page: "/settings" }, "User navigated");
// Вывод включает userId, sessionId И page

Для HTTP-серверов нужен дочерний логгер на каждый запрос, чтобы каждая строка лога в жизненном цикле запроса включала 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();
 
  // Прикрепляем дочерний логгер к запросу
  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,
  });
 
  // Устанавливаем заголовок request ID в ответе для корреляции
  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>();
 
// Получаем контекстный логгер из любого места в стеке вызовов
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(); // Автоматически содержит requestId!
 
  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 };
}
 
// Не нужно передавать логгер как параметр. Просто работает.

Агрегация логов: куда отправлять логи?#

В разработке логи идут в stdout, и pino-pretty делает их читаемыми. В продакшене всё сложнее.

Путь PM2#

Если ты работаешь на VPS с PM2 (о чём я рассказывал в статье о настройке VPS), PM2 перехватывает stdout автоматически:

bash
# Просмотр логов в реальном времени
pm2 logs api --lines 100
 
# Логи хранятся в ~/.pm2/logs/
# api-out.log  — stdout (твои JSON-логи)
# api-error.log — stderr (необработанные исключения, стек-трейсы)

Встроенная ротация логов 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 для вывода красиво отформатированного многострочного JSON в продакшене. Ты сломаешь каждый инструмент в пайплайне.

typescript
// НЕПРАВИЛЬНО в продакшене — многострочный JSON ломает построчную обработку
{
  "level": 30,
  "time": 1709312587000,
  "msg": "Request completed"
}
 
// ПРАВИЛЬНО в продакшене — NDJSON, один объект на строку
{"level":30,"time":1709312587000,"msg":"Request completed"}

Метрики с Prometheus#

Логи говорят, что произошло. Метрики говорят, как работает система. Разница — как между чтением каждой транзакции в банковской выписке и просмотром баланса счёта.

Четыре типа метрик#

У Prometheus четыре типа метрик. Понимание того, какой использовать когда, убережёт тебя от самых распространённых ошибок.

Counter — Значение, которое только растёт. Количество запросов, количество ошибок, обработанные байты. Сбрасывается в ноль при перезапуске.

typescript
// «Сколько запросов мы обслужили?»
const httpRequestsTotal = new Counter({
  name: "http_requests_total",
  help: "Total number of HTTP requests",
  labelNames: ["method", "route", "status_code"],
});

Gauge — Значение, которое может расти и падать. Текущие соединения, размер очереди, температура, использование кучи.

typescript
// «Сколько соединений активно прямо сейчас?»
const activeConnections = new Gauge({
  name: "active_connections",
  help: "Number of currently active connections",
});

Histogram — Наблюдает значения и считает их в настраиваемых бакетах. Длительность запроса, размер ответа. Именно так ты получаешь перцентили (p50, p95, p99).

typescript
// «Как долго выполняются запросы?» с бакетами на 10мс, 50мс, 100мс и т.д.
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";
 
// Создаём кастомный реестр, чтобы не засорять глобальный
export const metricsRegistry = new Registry();
 
// Собираем стандартные метрики Node.js:
// - 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_",
  // Собираем каждые 10 секунд
  gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
});
 
// --- HTTP-метрики ---
 
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],
});
 
// --- Бизнес-метрики ---
 
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";
 
// Нормализуем маршруты, чтобы избежать взрыва кардинальности
// /api/users/123 → /api/users/:id
// Без этого Prometheus создаст новый временной ряд для каждого user ID
function normalizeRoute(req: Request): string {
  const route = req.route?.path || req.path;
 
  // Заменяем типичные динамические сегменты
  return route
    .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "/:uuid")
    .replace(/\/\d+/g, "/:id")
    .replace(/\/[a-f0-9]{24}/g, "/:objectId");
}
 
export function metricsMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Не отслеживаем метрики для самого эндпоинта метрик
  if (req.path === "/metrics") {
    return next();
  }
 
  const end = httpRequestDuration.startTimer();
 
  res.on("finish", () => {
    const route = normalizeRoute(req);
    const labels = {
      method: req.method,
      route,
      status_code: res.statusCode.toString(),
    };
 
    httpRequestsTotal.inc(labels);
    end(labels);
  });
 
  next();
}

Эндпоинт /metrics#

typescript
// src/routes/metrics.ts
import { Router } from "express";
import { metricsRegistry } from "../lib/metrics";
 
const router = Router();
 
router.get("/metrics", async (req, res) => {
  // Базовая auth-защита — не выставляй метрики публично
  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
// В твоём сервисе заказов
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
 
export async function processOrder(order: Order) {
  try {
    // Замеряем вызов к платёжному провайдеру
    const paymentTimer = externalApiDuration.startTimer({
      service: "stripe",
      endpoint: "charges.create",
    });
 
    const charge = await stripe.charges.create({
      amount: order.total,
      currency: "usd",
      source: order.paymentToken,
    });
 
    paymentTimer({ status: "success" });
 
    ordersProcessed.inc({ status: "success" });
    return charge;
  } catch (err) {
    ordersProcessed.inc({ status: "failed" });
 
    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 как код#

Ты можешь версионировать дашборды с помощью фичи провизионирования 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
// Этот файл ДОЛЖЕН быть загружен до любых других импортов.
// В Node.js используйте флаг --require или --import.
 
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { Resource } from "@opentelemetry/resources";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
 
const sdk = new NodeSDK({
  resource: new Resource({
    [ATTR_SERVICE_NAME]: process.env.SERVICE_NAME || "node-api",
    [ATTR_SERVICE_VERSION]: process.env.APP_VERSION || "0.0.0",
    "deployment.environment": process.env.NODE_ENV || "development",
  }),
 
  // Отправляем трейсы в коллектор (Jaeger, Tempo и т.д.)
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
  }),
 
  // Опционально отправляем метрики через OTel тоже
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({
      url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
    }),
    exportIntervalMillis: 15000,
  }),
 
  // Автоинструментация: автоматически создаёт спаны для
  // HTTP-запросов, маршрутов Express, запросов PostgreSQL, команд Redis,
  // DNS-резолвов и многого другого
  instrumentations: [
    getNodeAutoInstrumentations({
      // Отключаем шумные инструментации
      "@opentelemetry/instrumentation-fs": { enabled: false },
      "@opentelemetry/instrumentation-dns": { enabled: false },
      // Настраиваем конкретные
      "@opentelemetry/instrumentation-http": {
        ignoreIncomingPaths: ["/health", "/ready", "/metrics"],
      },
      "@opentelemetry/instrumentation-express": {
        ignoreLayersType: ["middleware"],
      },
    }),
  ],
});
 
sdk.start();
 
// Корректное завершение
process.on("SIGTERM", () => {
  sdk.shutdown().then(
    () => console.log("OTel SDK shut down successfully"),
    (err) => console.error("Error shutting down OTel SDK", err)
  );
});

Запускай приложение так:

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);
 
      // Этот спан становится родительским для любых автоинструментированных
      // запросов к БД или HTTP-вызовов внутри этих функций
      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";
 
// При публикации сообщения
function publishEvent(queue: string, payload: object) {
  const carrier: Record<string, string> = {};
 
  // Инжектим текущий контекст трейса в carrier
  propagation.inject(context.active(), carrier);
 
  // Отправляем и payload, и контекст трейса
  messageQueue.publish(queue, {
    payload,
    traceContext: carrier,
  });
}
 
// При потреблении сообщения
function consumeEvent(message: QueueMessage) {
  // Извлекаем контекст трейса из сообщения
  const parentContext = propagation.extract(
    context.active(),
    message.traceContext
  );
 
  // Запускаем обработчик внутри извлечённого контекста
  // Теперь любые спаны, созданные здесь, будут дочерними для оригинального трейса
  context.with(parentContext, () => {
    tracer.startActiveSpan("processEvent", (span) => {
      span.setAttribute("queue.message_id", message.id);
      handleEvent(message.payload);
      span.end();
    });
  });
}

Куда отправлять трейсы#

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

Эндпоинты проверки здоровья#

Проверки здоровья — это простейшая форма наблюдаемости и первое, что следует реализовать. Они отвечают на один вопрос: «Способен ли этот сервис обслуживать запросы прямо сейчас?»

Три типа проверок здоровья#

/health — Общее здоровье. Работает ли процесс и отзывается ли он?

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

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

Различие важно для Kubernetes, где пробы живости перезапускают застрявшие контейнеры, а пробы готовности убирают контейнеры из балансировщика нагрузки во время запуска или отказов зависимостей.

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) {
  // Живость — просто проверяем, что процесс может ответить
  router.get("/live", (_req, res) => {
    res.status(200).json({ status: "ok" });
  });
 
  // Готовность — проверяем все зависимости
  router.get("/ready", async (_req, res) => {
    const [db, cache] = await Promise.all([
      checkDatabase(pool),
      checkRedis(redis),
    ]);
 
    const allOk = db.ok && cache.ok;
 
    res.status(allOk ? 200 : 503).json({
      status: allOk ? "ok" : "not_ready",
      checks: {
        database: db,
        redis: cache,
      },
    });
  });
 
  // Полное здоровье — детальный статус для дашбордов и отладки
  router.get("/health", async (_req, res) => {
    const [db, cache] = await Promise.all([
      checkDatabase(pool),
      checkRedis(redis),
    ]);
 
    const anyError = !db.ok || !cache.ok;
    const allError = !db.ok && !cache.ok;
 
    const result: HealthCheckResult = {
      status: allError ? "error" : anyError ? "degraded" : "ok",
      checks: {
        database: {
          status: db.ok ? "ok" : "error",
          latency: db.latency,
          ...(!db.ok && { message: "Connection failed" }),
        },
        redis: {
          status: cache.ok ? "ok" : "error",
          latency: cache.latency,
          ...(!cache.ok && { message: "Connection failed" }),
        },
      },
      uptime: process.uptime(),
      timestamp: new Date().toISOString(),
      version: process.env.APP_VERSION || "unknown",
    };
 
    // Возвращаем 200 для ok/degraded (сервис всё ещё может обрабатывать часть трафика)
    // Возвращаем 503 для error (сервис нужно убрать из ротации)
    res.status(result.status === "error" ? 503 : 200).json(result);
  });
 
  return router;
}

Конфигурация проб Kubernetes#

yaml
# k8s/deployment.yml
spec:
  containers:
    - name: api
      livenessProbe:
        httpGet:
          path: /live
          port: 3000
        initialDelaySeconds: 10
        periodSeconds: 15
        timeoutSeconds: 5
        failureThreshold: 3    # Перезапуск после 3 последовательных неудач (45с)
      readinessProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 5
        periodSeconds: 10
        timeoutSeconds: 5
        failureThreshold: 2    # Убрать из LB после 2 неудач (20с)
      startupProbe:
        httpGet:
          path: /ready
          port: 3000
        initialDelaySeconds: 0
        periodSeconds: 5
        failureThreshold: 30   # Даём до 150с на запуск

Распространённая ошибка: делать пробу живости слишком агрессивной. Если проба живости проверяет базу данных, а база временно недоступна, Kubernetes перезапустит контейнер. Но перезапуск не починит базу данных. Теперь у тебя crash loop поверх отказа базы данных. Делай пробы живости простыми — они должны обнаруживать только дедлоки или зависшие процессы.

Отслеживание ошибок с Sentry#

Логи ловят ошибки, которые ты ожидал. Sentry ловит те, которых нет.

Разница важна. Ты добавляешь try/catch блоки вокруг кода, который, по-твоему, может упасть. Но баги, которые важнее всего — в коде, который ты считал безопасным. Необработанные отклонения промисов, ошибки типов из неожиданных API-ответов, обращение к null по optional chain, который оказался не таким уж 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",
 
    // Сэмплируем 10% транзакций для мониторинга производительности
    // (100% в разработке)
    tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
 
    // Профилируем 100% сэмплированных транзакций
    profilesSampleRate: 1.0,
 
    integrations: [
      nodeProfilingIntegration(),
      // Фильтруем шумные ошибки
      Sentry.rewriteFramesIntegration({
        root: process.cwd(),
      }),
    ],
 
    // Не отправляем ошибки из разработки
    enabled: process.env.NODE_ENV === "production",
 
    // Фильтруем известные не-проблемы
    ignoreErrors: [
      // Отключения клиентов — не баги
      "ECONNRESET",
      "ECONNABORTED",
      "EPIPE",
      // Боты шлют мусор
      "SyntaxError: Unexpected token",
    ],
 
    // Убираем PII перед отправкой
    beforeSend(event) {
      // Убираем IP-адреса
      if (event.request) {
        delete event.request.headers?.["x-forwarded-for"];
        delete event.request.headers?.["x-real-ip"];
        delete event.request.cookies;
      }
 
      // Убираем чувствительные query-параметры
      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 с 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 должен быть первым
export const sentryRequestHandler = Sentry.Handlers.requestHandler();
 
// Обработчик трассировки Sentry
export const sentryTracingHandler = Sentry.Handlers.tracingHandler();
 
// Твой кастомный обработчик ошибок идёт последним
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  const log = getLogger();
 
  // Добавляем кастомный контекст к событию Sentry
  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,
        // Не отправляем email или username в Sentry
      });
    }
 
    // Добавляем хлебные крошки для отладки
    scope.addBreadcrumb({
      category: "request",
      message: `${req.method} ${req.path}`,
      level: "info",
      data: {
        query: req.query,
        statusCode: res.statusCode,
      },
    });
 
    Sentry.captureException(err);
  });
 
  // Логируем ошибку с полным контекстом
  log.error(
    {
      err,
      statusCode: 500,
      route: req.route?.path || req.path,
      method: req.method,
    },
    "Unhandled error in request handler"
  );
 
  // Отправляем общий ответ об ошибке
  // Никогда не раскрывай детали ошибки клиенту в продакшене
  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
# В твоём CI/CD пайплайне
npx @sentry/cli sourcemaps upload \
  --org your-org \
  --project your-project \
  --release $APP_VERSION \
  ./dist

Или настрой в бандлере:

typescript
// vite.config.ts (или эквивалент)
import { sentryVitePlugin } from "@sentry/vite-plugin";
 
export default defineConfig({
  build: {
    sourcemap: true, // Обязательно для Sentry
  },
  plugins: [
    sentryVitePlugin({
      org: process.env.SENTRY_ORG,
      project: process.env.SENTRY_PROJECT,
      authToken: process.env.SENTRY_AUTH_TOKEN,
    }),
  ],
});

Цена необработанных отклонений промисов#

Начиная с Node.js 15, необработанные отклонения промисов по умолчанию крашат процесс. Это хорошо — заставляет обрабатывать ошибки. Но нужна страховочная сеть:

typescript
// src/server.ts — в начале точки входа
 
process.on("unhandledRejection", (reason, promise) => {
  logger.fatal({ reason, promise }, "Unhandled promise rejection — crashing");
  Sentry.captureException(reason);
 
  // Отправляем события Sentry перед крашем
  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 алертов, которые срабатывают каждый день — это усталость от алертов — ты начнёшь их игнорировать, и пропустишь тот единственный, который действительно важен.

Цель — небольшое количество высокосигнальных алертов, которые означают «что-то реально не так, и человек должен посмотреть».

Конфигурация 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:
      # Высокая частота ошибок — что-то сломано
      - 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"
 
      # Медленные ответы — пользователи страдают
      - 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 }}"
 
      # Утечка памяти — скоро OOM
      - alert: HighHeapUsage
        expr: |
          (
            nodeapp_nodejs_heap_size_used_bytes
            /
            nodeapp_nodejs_heap_size_total_bytes
          ) > 0.80
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "Heap usage above 80% for 10 minutes"
          description: "Heap usage is at {{ $value | humanizePercentage }}"
 
      # Процесс лежит
      - 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 тормозит
      - 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 }}"
 
      # Трафик значительно упал — возможная проблема маршрутизации
      - 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"
 
      # Запросы к базе данных тормозят
      - 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 }}"
 
      # Внешний API сбоит
      - 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";
 
// Инициализируем Sentry первым — до других импортов
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 важен ---
 
// 1. Обработчик запросов Sentry (должен быть первым)
app.use(sentryRequestHandler);
app.use(sentryTracingHandler);
 
// 2. Асинхронный контекст (создаёт контекст запроса)
app.use(asyncContextMiddleware);
 
// 3. Логирование запросов
app.use(requestLogger);
 
// 4. Сбор метрик
app.use(metricsMiddleware);
 
// 5. Парсинг тела запроса
app.use(express.json({ limit: "1mb" }));
 
// --- Маршруты ---
 
// Проверки здоровья (не требуют авторизации)
app.use(createHealthRoutes(pool, redis));
 
// Метрики (защищены базовой аутентификацией)
app.use(metricsRouter);
 
// API-маршруты
app.use("/api", apiRouter);
 
// --- Обработка ошибок ---
 
// Обработчик ошибок Sentry (должен быть перед кастомным обработчиком)
app.use(Sentry.Handlers.errorHandler());
 
// Кастомный обработчик ошибок (должен быть последним)
app.use(errorHandler);
 
// --- Запуск ---
 
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"
  );
});
 
// Корректное завершение
async function shutdown(signal: string) {
  logger.info({ signal }, "Shutdown signal received");
 
  // Прекращаем принимать новые соединения
  // Обрабатываем текущие запросы (Express делает это автоматически)
 
  // Закрываем пул базы данных
  await pool.end().catch((err) => {
    logger.error({ err }, "Error closing database pool");
  });
 
  // Закрываем соединение Redis
  await redis.quit().catch((err) => {
    logger.error({ err }, "Error closing Redis connection");
  });
 
  // Отправляем буфер Sentry
  await Sentry.close(2000);
 
  logger.info("Shutdown complete");
  process.exit(0);
}
 
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

Минимально жизнеспособный стек#

Всё выше — «полный» стек. Тебе не нужно всё это с первого дня. Вот как масштабировать наблюдаемость по мере роста проекта.

Этап 1: Пет-проект / Соло-разработчик#

Тебе нужны три вещи:

  1. Структурированные консольные логи — Используй Pino, выводи JSON в stdout. Даже если просто читаешь их через pm2 logs, JSON-логи поисковые и парсируемые.

  2. Эндпоинт /health — Реализация занимает 5 минут, спасает, когда ты отлаживаешь «а оно вообще работает?»

  3. Бесплатный тариф Sentry — Ловит ошибки, которые ты не предвидел. Бесплатный тариф даёт 5 000 событий/месяц, чего вполне достаточно для пет-проекта.

typescript
// Это минимальный сетап. Меньше 50 строк. Отговорок нет.
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% в продакшене). Адаптивный сэмплинг — преждевременная оптимизация для большинства команд.

Заключительные мысли#

Наблюдаемость — это не продукт, который ты покупаешь, или инструмент, который устанавливаешь. Это практика. Это разница между управлением сервисом и надеждой, что сервис управляет собой.

Стек, который я описал — Pino для логов, Prometheus для метрик, OpenTelemetry для трейсов, Sentry для ошибок, Grafana для визуализации, AlertManager для алертов — это не самый простой возможный сетап. Но каждая часть заслуживает своего места, отвечая на вопрос, на который другие части ответить не могут.

Начни со структурированных логов и эндпоинта здоровья. Добавь метрики, когда нужно знать «насколько плохо». Добавь трейсы, когда нужно знать «куда уходит время». Каждый слой строится на предыдущем, и ни один не требует переписывать приложение.

Лучшее время добавить наблюдаемость было до последнего продакшн-инцидента. Второе лучшее — сейчас.

Похожие записи