Логи и метрики приложения

Наблюдаемость приложения, или observability, — способность понимать внутреннее состояние системы по данным, которые она создаёт во время работы.

Обычно наблюдаемость строится на трёх основных сигналах:

Сигнал На какой вопрос отвечает
Логи Что именно произошло?
Метрики Насколько часто и насколько быстро это происходит?
Трейсы Как запрос прошёл через компоненты системы?

Пример расследования ошибки:

Метрика показывает рост HTTP 500
                 ↓
Трейс находит медленный или ошибочный сервис
                 ↓
Логи объясняют конкретную причину ошибки

Логи, метрики и трейсы дополняют друг друга, но не заменяют друг друга.

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

Содержание


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

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

Обычный текстовый лог:

User 42 created order 815

Такое сообщение понятно человеку, но его неудобно автоматически фильтровать и анализировать.

Структурированный лог хранит событие как объект с отдельными полями:

{
  "timestamp": "2026-09-16T14:30:00.000Z",
  "level": "info",
  "message": "Order created",
  "service": "order-api",
  "environment": "production",
  "requestId": "req-6c21b86d",
  "userId": "user-42",
  "orderId": "order-815",
  "durationMs": 84
}

Теперь внешняя система может искать события по полям:

service = order-api
level = error
requestId = req-6c21b86d
orderId = order-815

Структурированные логи обычно записываются в формате JSON, по одной записи на строку.


Преимущества структурированных логов

Структурированное логирование позволяет:

Нежелательно помещать все данные в строку:

console.log(
  `User ${user.id} created order ${order.id} in ${duration}ms`,
);

Предпочтительно использовать отдельные поля:

logger.info(
  {
    userId: user.id,
    orderId: order.id,
    durationMs: duration,
  },
  "Order created",
);

Базовая структура записи

Полезные стандартные поля:

{
  "timestamp": "2026-09-16T14:30:00.000Z",
  "level": "info",
  "message": "Order created",
  "service": "order-api",
  "serviceVersion": "2.4.1",
  "environment": "production",
  "requestId": "req-6c21b86d",
  "traceId": "dc0a67e7b61e4acb9b3c051a6c753041",
  "spanId": "f9c48ea67c7f30ad",
  "userId": "user-42",
  "orderId": "order-815",
  "durationMs": 84
}

Назначение полей:

Поле Значение
timestamp Время события
level Уровень важности
message Краткое описание
service Имя приложения или сервиса
serviceVersion Версия развёрнутого приложения
environment Окружение
requestId Идентификатор HTTP-запроса
traceId Идентификатор распределённого трейса
spanId Идентификатор отдельной операции
userId Внутренний ID пользователя
durationMs Длительность операции
error Структурированная информация об ошибке

Названия полей должны быть единообразными во всех компонентах.

Нежелательно использовать разные варианты для одного значения:

requestId
request_id
reqId
correlationId
correlation_id

Следует выбрать одно соглашение и применять его последовательно.


Логгер для Node.js

Для структурированного логирования часто используются библиотеки, которые умеют:

Пример с логгером, поддерживающим структурированные записи:

import pino from "pino";

export const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",

  base: {
    service: "order-api",
    environment:
      process.env.NODE_ENV ?? "development",
    serviceVersion:
      process.env.APP_VERSION ?? "unknown",
  },

  redact: {
    paths: [
      "password",
      "passwordHash",
      "accessToken",
      "refreshToken",
      "authorization",
      "cookie",
      "req.headers.authorization",
      "req.headers.cookie",
    ],
    censor: "[REDACTED]",
  },
});

Использование:

logger.info(
  {
    orderId: "order-815",
    userId: "user-42",
  },
  "Order created",
);

Ошибка:

try {
  await orderService.create(input);
} catch (error) {
  logger.error(
    {
      error,
      userId: input.userId,
    },
    "Order creation failed",
  );

  throw error;
}

Конкретная форма сериализации Error зависит от логирующей библиотеки. Следует проверить, что в журнал действительно попадают:


Логирование ошибок

Стандартный объект Error содержит свойства, которые не всегда перечисляются обычной JSON-сериализацией:

const error = new Error("Database unavailable");

console.log(JSON.stringify(error));
// Часто: {}

При самостоятельном логгере ошибку нужно сериализовать явно:

function serializeError(error) {
  if (!(error instanceof Error)) {
    return {
      message: String(error),
    };
  }

  return {
    name: error.name,
    message: error.message,
    stack: error.stack,
    code: error.code,
    cause: error.cause
      ? serializeError(error.cause)
      : undefined,
  };
}

Использование:

logger.error({
  message: "Order creation failed",
  error: serializeError(error),
  requestId,
});

Не следует отправлять полный стек вызовов клиенту:

return response.status(500).json({
  stack: error.stack,
});

Клиенту возвращается безопасный ответ:

return response.status(500).json({
  error: "internal_error",
  message: "Не удалось обработать запрос",
  requestId: request.id,
});

А технические детали сохраняются в защищённом журнале.


Логи событий и логи состояния

Полезно записывать значимые события:

logger.info(
  {
    userId: user.id,
  },
  "User registered",
);
logger.warn(
  {
    userId: user.id,
    failedAttempts: 8,
  },
  "Multiple authentication failures detected",
);
logger.error(
  {
    error,
    paymentId,
  },
  "Payment provider request failed",
);

Не следует на каждом шаге записывать весь объект приложения:

logger.info({
  request,
  user,
  config,
  database,
});

Такие записи:


Что нельзя записывать в логи

Не следует журналировать:

Небезопасно:

logger.info({
  body: request.body,
  headers: request.headers,
});

В запросе могут находиться:

password
Authorization
Cookie
token
personal data

Предпочтительно выбирать разрешённые поля:

logger.info(
  {
    method: request.method,
    path: request.path,
    contentLength:
      request.headers["content-length"],
    userId: request.user?.id,
  },
  "Request received",
);

Маскирование данных

Если поле необходимо для диагностики, значение можно частично скрыть:

function maskEmail(email) {
  const [localPart, domain] =
    email.split("@");

  if (!domain) {
    return "[INVALID_EMAIL]";
  }

  const visiblePart =
    localPart.slice(0, 2);

  return `${visiblePart}***@${domain}`;
}

Использование:

logger.info(
  {
    email: maskEmail(user.email),
  },
  "Password reset requested",
);

Другой вариант — записывать стабильный односторонний идентификатор для сопоставления событий:

import { createHmac } from "node:crypto";

function createAuditIdentifier(value) {
  return createHmac(
    "sha256",
    process.env.AUDIT_HASH_KEY,
  )
    .update(value)
    .digest("hex");
}

Обычный несолёный хеш email может быть восстановлен перебором распространённых адресов, поэтому для псевдонимизации полезнее keyed hash.


Логи в stdout

В контейнерных и облачных окружениях приложение обычно пишет структурированные логи в стандартный вывод:

process.stdout.write(
  `${JSON.stringify(logEntry)}\n`,
);

Дальше инфраструктура собирает поток:

Приложение
    ↓ stdout / stderr
Контейнерная платформа
    ↓
Агент или коллектор
    ↓
Центральное хранилище
    ↓
Поиск, панели и оповещения

Приложению обычно не нужно самостоятельно:

Эти обязанности лучше передать инфраструктурному агенту или платформе.


Уровни логирования

Уровень показывает важность и назначение события.

Распространённые уровни:

trace
debug
info
warn
error
fatal

Порядок важности:

trace < debug < info < warn < error < fatal

Если минимальный уровень равен info, записи trace и debug обычно не создаются.


trace

Очень подробная информация о внутреннем выполнении.

logger.trace(
  {
    cacheKey,
    attempt,
  },
  "Checking cache entry",
);

Подходит для:

В production уровень trace обычно отключён из-за большого объёма данных.


debug

Диагностическая информация для разработчиков:

logger.debug(
  {
    orderId,
    itemCount: items.length,
  },
  "Calculating order total",
);

Подходит для:

debug не должен быть необходим для нормального мониторинга production. Значимые рабочие события следует записывать как info, warn или error.


info

Ожидаемое значимое событие нормальной работы:

logger.info(
  {
    orderId,
    userId,
  },
  "Order created",
);

Примеры:

Не следует записывать info для каждого незначительного шага. Иначе важные события потеряются в шуме.


warn

Неожиданная или потенциально опасная ситуация, после которой приложение продолжает работать:

logger.warn(
  {
    service: "payment-provider",
    attempt: 2,
  },
  "External request will be retried",
);

Примеры:

warn не следует использовать для обычных ожидаемых событий:

logger.warn("User entered invalid password");

Одна неверная попытка входа обычно является нормальной ситуацией. Предупреждение уместнее при обнаружении подозрительной серии попыток.


error

Операция завершилась ошибкой, но приложение продолжает работать:

logger.error(
  {
    error,
    orderId,
    paymentProvider: "example-pay",
  },
  "Payment operation failed",
);

Примеры:

Не каждый клиентский ответ 4xx является error.

Например:

400 — пользователь отправил некорректные данные
404 — ресурс не найден
409 — конфликт состояния

Такие события часто записываются как info или warn, а не как ошибка приложения.


fatal

Критическая ошибка, после которой процесс не может корректно продолжать работу:

logger.fatal(
  {
    error,
  },
  "Application startup failed",
);

process.exit(1);

Примеры:

Перед завершением нужно по возможности дать логгеру завершить запись и корректно остановить ресурсы.


Таблица выбора уровня

Событие Уровень
Подробный проход алгоритма trace
Диагностические данные разработчика debug
Нормальное значимое событие info
Деградация или подозрительная ситуация warn
Операция завершилась ошибкой error
Приложение не может продолжать работу fatal

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


Динамический уровень

Уровень можно задавать конфигурацией:

const logLevel =
  process.env.LOG_LEVEL ?? "info";

Типичные значения:

development — debug
test        — warn или silent
production  — info

Не следует постоянно держать debug и trace в production без оценки объёма и риска утечки данных.


Correlation ID

Correlation ID, или Request ID, — идентификатор, связывающий все события одного запроса.

Без идентификатора:

{
  "level": "error",
  "message": "Database query failed"
}

Непонятно, к какому HTTP-запросу относится ошибка.

С идентификатором:

{
  "level": "error",
  "message": "Database query failed",
  "requestId": "req-6c21b86d"
}

Теперь можно найти весь путь запроса:

requestId = req-6c21b86d

Создание Request ID

Клиент или доверенный прокси может передать:

X-Request-ID: req-6c21b86d

Если заголовка нет, приложение создаёт значение самостоятельно:

import { randomUUID } from "node:crypto";

function requestIdMiddleware(
  request,
  response,
  next,
) {
  const incomingRequestId =
    request.headers["x-request-id"];

  const requestId =
    typeof incomingRequestId === "string" &&
    /^[a-zA-Z0-9._-]{1,128}$/.test(
      incomingRequestId,
    )
      ? incomingRequestId
      : randomUUID();

  request.id = requestId;

  response.setHeader(
    "X-Request-ID",
    requestId,
  );

  next();
}

Входящий идентификатор следует валидировать и ограничивать по длине. Иначе клиент может передать:

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


Дочерний логгер запроса

function requestLoggerMiddleware(
  request,
  response,
  next,
) {
  request.log = logger.child({
    requestId: request.id,
  });

  next();
}

Использование:

request.log.info(
  {
    userId: request.user?.id,
  },
  "Profile requested",
);

Дочерний логгер автоматически добавляет requestId к каждой записи.


Контекст запроса через AsyncLocalStorage

Передавать request.log во все функции может быть неудобно. В Node.js контекст запроса можно хранить через AsyncLocalStorage.

import {
  AsyncLocalStorage,
} from "node:async_hooks";

export const requestContext =
  new AsyncLocalStorage();

Middleware:

import { randomUUID } from "node:crypto";

function requestContextMiddleware(
  request,
  response,
  next,
) {
  const requestId =
    request.id ?? randomUUID();

  const context = {
    requestId,
    startedAt: performance.now(),
  };

  requestContext.run(context, next);
}

Получение текущего контекста:

function getRequestContext() {
  return requestContext.getStore() ?? {};
}

Обёртка логгера:

function logInfo(message, data = {}) {
  logger.info(
    {
      ...getRequestContext(),
      ...data,
    },
    message,
  );
}

Использование в сервисе:

class OrderService {
  async createOrder(input) {
    logInfo("Creating order", {
      userId: input.userId,
    });

    // ...
  }
}

AsyncLocalStorage помогает передавать контекст через асинхронную цепочку, но нужно тестировать его работу с используемыми библиотеками и нестандартными callback-механизмами.


Логирование начала и завершения запроса

function httpLoggingMiddleware(
  request,
  response,
  next,
) {
  const startedAt = performance.now();

  request.log.info(
    {
      method: request.method,
      path: request.path,
    },
    "HTTP request started",
  );

  response.on("finish", () => {
    const durationMs =
      performance.now() - startedAt;

    const logData = {
      method: request.method,
      route:
        request.route?.path ??
        "unmatched",
      statusCode: response.statusCode,
      durationMs: Number(
        durationMs.toFixed(2),
      ),
    };

    if (response.statusCode >= 500) {
      request.log.error(
        logData,
        "HTTP request completed",
      );
    } else if (
      response.statusCode >= 400
    ) {
      request.log.warn(
        logData,
        "HTTP request completed",
      );
    } else {
      request.log.info(
        logData,
        "HTTP request completed",
      );
    }
  });

  next();
}

Событие finish означает, что ответ был передан HTTP-слою.

Для обнаружения прерванных соединений можно также учитывать close, не создавая две одинаковые записи:

function httpLoggingMiddleware(
  request,
  response,
  next,
) {
  const startedAt = performance.now();
  let completed = false;

  response.on("finish", () => {
    completed = true;

    request.log.info(
      {
        statusCode: response.statusCode,
        durationMs:
          performance.now() - startedAt,
      },
      "HTTP request completed",
    );
  });

  response.on("close", () => {
    if (completed) {
      return;
    }

    request.log.warn(
      {
        durationMs:
          performance.now() - startedAt,
      },
      "HTTP connection closed before completion",
    );
  });

  next();
}

Распределённый трейсинг

Request ID полезен внутри одного приложения, но в распределённой системе запрос проходит через несколько компонентов:

Browser
   ↓
API Gateway
   ↓
Order Service
   ↓
Payment Service
   ↓
Database

Распределённый трейсинг связывает операции через:

Пример:

Trace: checkout
├── Span: POST /orders
├── Span: SELECT products
├── Span: calculate total
├── Span: POST payment-service
│   └── Span: payment-provider request
└── Span: INSERT order

Correlation ID и Trace ID

Понятия похожи, но не полностью одинаковы.

Request ID — идентификатор конкретного входящего запроса
Trace ID   — идентификатор распределённой цепочки операций
Span ID    — идентификатор отдельной операции внутри трейса

Один trace может включать несколько запросов и фоновых операций.

Пример:

traceId:  aabbcc...
spanId:   111111...
requestId: req-api-1

После обращения к следующему сервису:

traceId:  aabbcc...
spanId:   222222...
requestId: req-payment-7

traceId остаётся общим, а локальный requestId может измениться.


Передача контекста

Для межсервисного трейсинга применяется стандартизированный заголовок traceparent:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

Упрощённая структура:

version-traceId-parentSpanId-flags

Не рекомендуется вручную реализовывать полноценную обработку распределённого контекста. Обычно используется библиотека трейсинга, например инструменты экосистемы OpenTelemetry.


OpenTelemetry

OpenTelemetry предоставляет единый подход к:

Концептуальная ручная операция:

const tracer =
  telemetry.getTracer("order-service");

async function createOrder(input) {
  return tracer.startActiveSpan(
    "create-order",
    async (span) => {
      try {
        span.setAttribute(
          "app.user_id",
          input.userId,
        );

        const order =
          await orderRepository.create(
            input,
          );

        span.setAttribute(
          "app.order_id",
          order.id,
        );

        return order;
      } catch (error) {
        span.recordException(error);
        span.setStatus({
          code: "error",
          message: error.message,
        });

        throw error;
      } finally {
        span.end();
      }
    },
  );
}

На практике базовые HTTP-запросы, обращения к базе и некоторым библиотекам часто инструментируются автоматически.

В логи полезно добавлять текущие traceId и spanId, чтобы переходить между логами и трейсами.


Трассировка фоновых задач

Контекст запроса может быть потерян после помещения задачи в очередь:

HTTP request
    ↓
Message queue
    ↓
Background worker

При публикации сообщения передаётся контекст:

await queue.publish({
  type: "order.created",
  payload: {
    orderId: order.id,
  },
  metadata: {
    requestId: request.id,
    traceparent:
      request.headers.traceparent,
  },
});

Worker извлекает контекст и создаёт дочерний span либо собственный связанный trace в зависимости от модели системы.


Метрики приложения

Метрика — числовое измерение состояния или поведения системы во времени.

Примеры:

Количество HTTP-запросов
Длительность ответа
Число ошибок
Количество активных соединений
Размер очереди
Число обработанных задач

Метрики используются для:


Основные типы метрик

Counter

Counter — монотонно возрастающее значение.

Примеры:

Общее число запросов
Общее число ошибок
Общее число обработанных сообщений
httpRequestsTotal.inc();

Counter не следует уменьшать вручную. Скорость вычисляется системой мониторинга на основании изменения значения во времени.


Gauge

Gauge — значение, которое может увеличиваться и уменьшаться.

Примеры:

Число активных запросов
Размер очереди
Используемая память
Число открытых соединений
activeRequests.inc();

// Выполнение запроса

activeRequests.dec();

Histogram

Histogram распределяет наблюдения по диапазонам.

Подходит для:

Длительности запросов
Размеров ответов
Времени SQL-запросов
Размеров файлов
requestDuration.observe(0.245);

Гистограмма позволяет вычислять распределение и приблизительные квантили на стороне системы мониторинга.


Summary

Summary также измеряет распределение значений и может вычислять квантили на стороне приложения.

В распределённых системах histogram обычно удобнее агрегировать между экземплярами. Выбор зависит от системы метрик и требуемой точности.


RPS

RPS (Requests Per Second) — количество запросов в секунду.

Упрощённая формула:

RPS = количество запросов / период в секундах

Например, если за минуту обработано 6000 запросов:

6000 / 60 = 100 RPS

В приложении обычно хранится не RPS как готовое значение, а счётчик запросов:

http_requests_total

Система мониторинга вычисляет скорость изменения счётчика.

RPS полезно анализировать по:


Latency

Latency — время выполнения операции.

Для HTTP это обычно время от получения запроса до завершения ответа.

Request received
       ↓
Processing
       ↓
Response completed

Измерение:

const startedAt = performance.now();

await handleRequest();

const durationMs =
  performance.now() - startedAt;

Одного среднего значения недостаточно.

Допустим:

99 запросов — 20ms
1 запрос    — 10 000ms

Среднее не покажет, насколько плох был медленный запрос для конкретного пользователя.

Поэтому используются процентили:

Процентиль Значение
p50 Половина запросов быстрее этого времени
p90 90% запросов быстрее
p95 95% запросов быстрее
p99 99% запросов быстрее

Пример:

p50 = 80ms
p95 = 350ms
p99 = 1200ms

Это означает, что редкая часть запросов заметно медленнее основной.


Errors

Метрика ошибок показывает количество или долю неуспешных операций.

Количество ошибок:

http_requests_total{
  status_class="5xx"
}

Доля ошибок:

error rate =
ошибочные запросы / все запросы

Например:

50 ошибок / 10 000 запросов = 0.5%

При анализе HTTP обычно разделяют:

4xx — клиентские ошибки
5xx — серверные ошибки

Но классификация зависит от продукта.

Например, резкий рост 401 может означать:

Поэтому 4xx тоже должны наблюдаться, даже если они не считаются ошибками сервера.


RED-метод

Для сервисов удобно использовать модель RED:

Rate      — частота запросов
Errors    — число или доля ошибок
Duration  — длительность запросов

Минимальный набор HTTP-метрик:

http_requests_total
http_request_duration_seconds
http_requests_in_progress

С дополнительными labels:

method
route
status_code или status_class

USE-метод

Для инфраструктурных ресурсов применяется модель USE:

Utilization — доля использования ресурса
Saturation  — накопившаяся очередь или конкуренция
Errors      — ошибки ресурса

Примеры:

CPU utilization
Memory usage
Event loop lag
Database connection pool utilization
Queue length
Disk errors

RED больше подходит для пользовательских запросов, USE — для ресурсов системы.


Метрики в Node.js

Пример экспортирования метрик в формате, совместимом с системами мониторинга, использующими модель Prometheus:

import client from "prom-client";

const registry = new client.Registry();

client.collectDefaultMetrics({
  register: registry,
  prefix: "order_api_",
});

Счётчик запросов:

const httpRequestsTotal =
  new client.Counter({
    name: "order_api_http_requests_total",
    help: "Total number of HTTP requests",
    labelNames: [
      "method",
      "route",
      "status_class",
    ],
    registers: [registry],
  });

Гистограмма длительности:

const httpRequestDuration =
  new client.Histogram({
    name:
      "order_api_http_request_duration_seconds",
    help:
      "Duration of HTTP requests in seconds",
    labelNames: [
      "method",
      "route",
      "status_class",
    ],
    buckets: [
      0.005,
      0.01,
      0.025,
      0.05,
      0.1,
      0.25,
      0.5,
      1,
      2.5,
      5,
    ],
    registers: [registry],
  });

Gauge активных запросов:

const httpRequestsInProgress =
  new client.Gauge({
    name:
      "order_api_http_requests_in_progress",
    help:
      "Number of HTTP requests currently being processed",
    registers: [registry],
  });

Middleware метрик

function metricsMiddleware(
  request,
  response,
  next,
) {
  const startedAt = performance.now();

  httpRequestsInProgress.inc();

  response.on("finish", () => {
    httpRequestsInProgress.dec();

    const durationSeconds =
      (performance.now() - startedAt) /
      1000;

    const statusClass =
      `${Math.floor(
        response.statusCode / 100,
      )}xx`;

    const route =
      request.route?.path ??
      "unmatched";

    const labels = {
      method: request.method,
      route,
      status_class: statusClass,
    };

    httpRequestsTotal.inc(labels);

    httpRequestDuration.observe(
      labels,
      durationSeconds,
    );
  });

  next();
}

В Express окончательный маршрут может быть доступен только после выполнения обработчика. Для вложенных роутеров может потребоваться объединить baseUrl и шаблон маршрута:

const route =
  request.route?.path
    ? `${request.baseUrl}${request.route.path}`
    : "unmatched";

Endpoint метрик

app.get(
  "/metrics",
  async (request, response) => {
    response.setHeader(
      "Content-Type",
      registry.contentType,
    );

    response.send(
      await registry.metrics(),
    );
  },
);

Endpoint метрик не следует без необходимости публиковать в интернете.

Его можно ограничить:

Метрики могут раскрывать:


Cardinality метрик

Cardinality — количество уникальных комбинаций label-значений.

Низкая cardinality:

method = GET | POST | PATCH | DELETE
status_class = 2xx | 4xx | 5xx
route = /users/:userId

Опасно использовать уникальные значения:

userId
requestId
email
orderId
full URL
error message

Нежелательно:

httpRequestsTotal.inc({
  path: request.originalUrl,
  userId: request.user.id,
});

Запросы:

/users/1
/users/2
/users/3

создадут отдельные временные ряды.

Предпочтительно:

httpRequestsTotal.inc({
  route: "/users/:userId",
  method: "GET",
  status_class: "2xx",
});

Уникальные идентификаторы следует помещать в логи и трейсы, а не в labels метрик.


Бизнес-метрики

Кроме технических метрик полезны показатели бизнес-операций:

orders_created_total
payments_succeeded_total
payments_failed_total
messages_processed_total
jobs_completed_total
jobs_failed_total

Пример:

const ordersCreatedTotal =
  new client.Counter({
    name:
      "order_api_orders_created_total",
    help:
      "Total number of created orders",
    labelNames: ["channel"],
    registers: [registry],
  });

После успешного создания:

ordersCreatedTotal.inc({
  channel: "web",
});

Необходимо определить, когда операция считается успешной.

Например:

Заказ записан в базу
или
Заказ оплачен
или
Заказ передан на обработку

Название и описание метрики должны отражать точную семантику.


Метрики внешних зависимостей

Полезно измерять обращения к:

Минимальный набор:

dependency_requests_total
dependency_request_duration_seconds
dependency_errors_total

Пример обёртки:

async function measureDependencyCall({
  dependency,
  operation,
  execute,
}) {
  const startedAt = performance.now();

  try {
    const result = await execute();

    dependencyRequestsTotal.inc({
      dependency,
      operation,
      outcome: "success",
    });

    return result;
  } catch (error) {
    dependencyRequestsTotal.inc({
      dependency,
      operation,
      outcome: "error",
    });

    throw error;
  } finally {
    dependencyDuration.observe(
      {
        dependency,
        operation,
      },
      (performance.now() - startedAt) /
        1000,
    );
  }
}

Использование:

const user =
  await measureDependencyCall({
    dependency: "database",
    operation: "find_user",
    execute: () => {
      return userRepository.findById(
        userId,
      );
    },
  });

operation должна иметь ограниченный набор значений. Не следует использовать полный SQL-запрос как label.


Event loop и память Node.js

Кроме HTTP-метрик полезно наблюдать:

Большая задержка event loop может указывать на:

Стандартный сборщик метрик библиотеки может собирать часть процессных показателей автоматически.


Health-check endpoint

Health check — endpoint, сообщающий инфраструктуре о состоянии экземпляра приложения.

Обычно разделяют:

Liveness  — процесс жив и способен продолжать работу?
Readiness — экземпляр готов принимать запросы?
Startup   — приложение завершило запуск?

Использование одного /health для всех задач часто приводит к неправильному поведению инфраструктуры.


Liveness

Liveness отвечает на вопрос:

Нужно ли перезапустить процесс?

Простой endpoint:

app.get(
  "/health/live",
  (request, response) => {
    response.status(200).json({
      status: "ok",
    });
  },
);

Liveness не должен зависеть от временной недоступности базы или внешнего API.

Нежелательно:

app.get("/health/live", async (request, response) => {
  await database.query("SELECT 1");
  await paymentProvider.ping();
  await emailProvider.ping();

  response.json({
    status: "ok",
  });
});

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

Liveness обычно проверяет:


Readiness

Readiness отвечает на вопрос:

Можно ли направлять на этот экземпляр пользовательский трафик?

Пример:

app.get(
  "/health/ready",
  async (request, response) => {
    const checks =
      await runReadinessChecks();

    const isReady =
      checks.every(
        (check) =>
          check.status === "up",
      );

    response
      .status(isReady ? 200 : 503)
      .json({
        status: isReady
          ? "ready"
          : "not_ready",
        checks,
      });
  },
);

Проверки:

async function runReadinessChecks() {
  const databaseCheck =
    await checkDatabase();

  return [databaseCheck];
}

Проверка базы данных

async function checkDatabase() {
  const startedAt = performance.now();

  try {
    await withTimeout(
      database.query("SELECT 1"),
      1000,
    );

    return {
      name: "database",
      status: "up",
      durationMs: Number(
        (
          performance.now() -
          startedAt
        ).toFixed(2),
      ),
    };
  } catch {
    return {
      name: "database",
      status: "down",
      durationMs: Number(
        (
          performance.now() -
          startedAt
        ).toFixed(2),
      ),
    };
  }
}

Утилита тайм-аута:

function withTimeout(
  promise,
  timeoutMs,
) {
  return Promise.race([
    promise,

    new Promise((_, reject) => {
      const timeout = setTimeout(() => {
        reject(
          new Error(
            `Operation timed out after ${timeoutMs}ms`,
          ),
        );
      }, timeoutMs);

      timeout.unref?.();
    }),
  ]);
}

Если API зависимости поддерживает AbortSignal, предпочтительно отменять операцию, а не только прекращать ожидание результата.


Какие зависимости включать в readiness

Не каждая зависимость должна блокировать готовность.

Если приложение не может выполнить основную функцию без базы данных:

Database down → readiness = false

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

Email provider down → приложение остаётся ready

Состояние можно отразить как деградацию:

{
  "status": "ready",
  "checks": [
    {
      "name": "database",
      "status": "up"
    },
    {
      "name": "email",
      "status": "degraded"
    }
  ]
}

Не следует проверять все внешние системы без анализа. Health check может сам создать значительную нагрузку.


Startup probe

Startup endpoint показывает, завершена ли инициализация:

let startupCompleted = false;

async function startApplication() {
  await loadConfiguration();
  await connectToDatabase();
  await runRequiredInitialization();

  startupCompleted = true;
}

Endpoint:

app.get(
  "/health/startup",
  (request, response) => {
    if (!startupCompleted) {
      return response
        .status(503)
        .json({
          status: "starting",
        });
    }

    return response.status(200).json({
      status: "started",
    });
  },
);

Startup probe полезен, если приложение:


Безопасность health-check

Публичный health-check не должен раскрывать:

Публичный ответ:

{
  "status": "ready"
}

Подробный внутренний ответ:

{
  "status": "ready",
  "checks": [
    {
      "name": "database",
      "status": "up",
      "durationMs": 4.8
    }
  ]
}

Подробную версию можно ограничить внутренней сетью или аутентификацией.


Кэширование health-check

Health endpoint не должен кэшироваться:

function disableHealthCaching(
  request,
  response,
  next,
) {
  response.setHeader(
    "Cache-Control",
    "no-store",
  );

  next();
}

Использование:

app.use(
  "/health",
  disableHealthCaching,
);

Graceful shutdown

При остановке экземпляр должен сначала прекратить принимать новый трафик, а затем завершить текущие операции.

Упрощённая последовательность:

Получен SIGTERM
       ↓
Readiness становится false
       ↓
Балансировщик прекращает новые запросы
       ↓
Завершаются активные запросы
       ↓
Закрываются соединения
       ↓
Процесс завершается

Пример:

let shuttingDown = false;

app.get(
  "/health/ready",
  async (request, response) => {
    if (shuttingDown) {
      return response
        .status(503)
        .json({
          status: "not_ready",
        });
    }

    return response.status(200).json({
      status: "ready",
    });
  },
);

Обработка сигнала:

process.on("SIGTERM", async () => {
  shuttingDown = true;

  logger.info(
    "Graceful shutdown started",
  );

  server.close(async (error) => {
    if (error) {
      logger.error(
        {
          error,
        },
        "HTTP server shutdown failed",
      );

      process.exitCode = 1;
    }

    try {
      await database.close();
      await messageQueue.close();

      logger.info(
        "Graceful shutdown completed",
      );
    } catch (shutdownError) {
      logger.error(
        {
          error: shutdownError,
        },
        "Resource shutdown failed",
      );

      process.exitCode = 1;
    }
  });
});

Также нужен максимальный срок ожидания, после которого процесс принудительно завершается.


Интеграция с внешними системами логирования

Централизованная система собирает логи всех экземпляров:

Application instances
        ↓ JSON logs
Collector / Agent
        ↓
Central log storage
        ↓
Search / Dashboards / Alerts

Компоненты могут включать:


Распространённые варианты

Elasticsearch / OpenSearch

Схема:

Application
    ↓
Filebeat / Fluent Bit / Vector
    ↓
Elasticsearch или OpenSearch
    ↓
Kibana или OpenSearch Dashboards

Подходит для:

Следует контролировать:


Grafana Loki

Схема:

Application
    ↓
Collector
    ↓
Loki
    ↓
Grafana

Loki ориентирован на хранение и поиск логов с использованием labels.

В labels не следует помещать уникальные значения:

requestId
userId
orderId

Их лучше хранить внутри JSON-записи и искать по содержимому.

Низкокардинальные labels:

service
environment
region
level

Облачные системы

Облачные платформы предоставляют собственные системы:

Cloud logging
Managed log search
Managed metrics
Managed tracing
Managed alerts

Обычно платформа автоматически собирает stdout контейнеров или процессов.

Следует проверить:


OpenTelemetry Collector

Коллектор может принимать телеметрию и перенаправлять её в разные системы:

Application
    ↓ OTLP
OpenTelemetry Collector
    ├── Logs backend
    ├── Metrics backend
    └── Tracing backend

Преимущества:


Приложение не должно зависеть от доступности лог-хранилища

Нежелательно отправлять каждый лог синхронным HTTP-запросом:

await fetch(
  "https://logs.example.com/events",
  {
    method: "POST",
    body: JSON.stringify(logEntry),
  },
);

Проблемы:

Предпочтительная схема:

Приложение пишет в stdout
       ↓
Независимый агент собирает и отправляет данные

Если используется встроенный exporter, отправка должна быть:


Буферизация и backpressure

Если внешняя система недоступна, очередь логов может расти.

Необходимо определить:

Обычно лучше потерять часть диагностических debug-логов, чем остановить основную работу приложения из-за переполнения памяти.

События аудита могут требовать более надёжного отдельного канала.


Application logs и Audit logs

Рабочие логи и аудит решают разные задачи.

Application logs

Используются для диагностики:

Запрос завершён
База данных недоступна
Задача повторяется
Кэш очищен

Audit logs

Фиксируют значимые действия субъекта:

Пользователь изменил роль
Администратор удалил учётную запись
Изменены платёжные реквизиты
Создан новый API-ключ
Экспортированы данные

Пример аудита:

auditLogger.info({
  event: "user.role.changed",
  actorUserId: currentUser.id,
  targetUserId: targetUser.id,
  previousRole: "editor",
  newRole: "admin",
  requestId: request.id,
  timestamp: new Date().toISOString(),
});

Audit log должен быть:

Не следует хранить в аудите секреты или полное содержимое чувствительных данных.


Алерты

Метрика или лог сами по себе не уведомляют команду. Для важных условий создаются alert rules.

Примеры:

Доля 5xx превышает 2% в течение 10 минут
p95 latency превышает 1 секунду
Readiness нескольких экземпляров стала false
Очередь задач непрерывно растёт
Неуспешные входы резко увеличились
Свободных соединений с базой почти не осталось

Хороший alert должен:

Нежелательно создавать alert на каждую отдельную ошибку:

Один HTTP 500 → срочное уведомление

Лучше учитывать частоту, длительность и влияние:

Доля HTTP 500 > 2% в течение 10 минут

SLI, SLO и SLA

SLI — измеряемый показатель качества.

Примеры:

Доля успешных запросов
p95 latency
Доступность endpoint

SLO — целевое значение SLI:

99.9% запросов должны завершаться успешно за месяц

SLA — соглашение с пользователем или заказчиком, которое может включать последствия нарушения.

Пример SLI доступности:

успешные запросы / все допустимые запросы

Пример SLO:

Не менее 99.95% успешных запросов за 30 дней

Необходимо точно определить, какие запросы считаются:


Полный пример Express-приложения

import express from "express";
import pino from "pino";
import client from "prom-client";
import { randomUUID } from "node:crypto";

const app = express();

const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",

  base: {
    service: "example-api",
    environment:
      process.env.NODE_ENV ??
      "development",
    serviceVersion:
      process.env.APP_VERSION ??
      "unknown",
  },

  redact: {
    paths: [
      "password",
      "accessToken",
      "refreshToken",
      "authorization",
      "cookie",
      "req.headers.authorization",
      "req.headers.cookie",
    ],
    censor: "[REDACTED]",
  },
});

const registry =
  new client.Registry();

client.collectDefaultMetrics({
  register: registry,
  prefix: "example_api_",
});

const requestsTotal =
  new client.Counter({
    name:
      "example_api_http_requests_total",
    help:
      "Total number of HTTP requests",
    labelNames: [
      "method",
      "route",
      "status_class",
    ],
    registers: [registry],
  });

const requestDuration =
  new client.Histogram({
    name:
      "example_api_http_request_duration_seconds",
    help:
      "HTTP request duration in seconds",
    labelNames: [
      "method",
      "route",
      "status_class",
    ],
    buckets: [
      0.01,
      0.025,
      0.05,
      0.1,
      0.25,
      0.5,
      1,
      2.5,
      5,
    ],
    registers: [registry],
  });

const requestsInProgress =
  new client.Gauge({
    name:
      "example_api_http_requests_in_progress",
    help:
      "Number of requests being processed",
    registers: [registry],
  });

app.use(
  express.json({
    limit: "100kb",
  }),
);

app.use((request, response, next) => {
  const incomingId =
    request.headers["x-request-id"];

  request.id =
    typeof incomingId === "string" &&
    /^[a-zA-Z0-9._-]{1,128}$/.test(
      incomingId,
    )
      ? incomingId
      : randomUUID();

  request.log = logger.child({
    requestId: request.id,
  });

  response.setHeader(
    "X-Request-ID",
    request.id,
  );

  next();
});

app.use((request, response, next) => {
  const startedAt =
    performance.now();

  requestsInProgress.inc();

  request.log.info(
    {
      method: request.method,
      path: request.path,
    },
    "HTTP request started",
  );

  response.on("finish", () => {
    requestsInProgress.dec();

    const durationSeconds =
      (performance.now() - startedAt) /
      1000;

    const route =
      request.route?.path
        ? `${request.baseUrl}${request.route.path}`
        : "unmatched";

    const statusClass =
      `${Math.floor(
        response.statusCode / 100,
      )}xx`;

    const labels = {
      method: request.method,
      route,
      status_class: statusClass,
    };

    requestsTotal.inc(labels);

    requestDuration.observe(
      labels,
      durationSeconds,
    );

    const fields = {
      method: request.method,
      route,
      statusCode: response.statusCode,
      durationMs: Number(
        (
          durationSeconds * 1000
        ).toFixed(2),
      ),
    };

    if (response.statusCode >= 500) {
      request.log.error(
        fields,
        "HTTP request completed",
      );
    } else if (
      response.statusCode >= 400
    ) {
      request.log.warn(
        fields,
        "HTTP request completed",
      );
    } else {
      request.log.info(
        fields,
        "HTTP request completed",
      );
    }
  });

  next();
});

let startupCompleted = true;
let shuttingDown = false;

app.get(
  "/health/live",
  (request, response) => {
    response
      .setHeader(
        "Cache-Control",
        "no-store",
      )
      .status(200)
      .json({
        status: "ok",
      });
  },
);

app.get(
  "/health/startup",
  (request, response) => {
    response.setHeader(
      "Cache-Control",
      "no-store",
    );

    response
      .status(
        startupCompleted ? 200 : 503,
      )
      .json({
        status: startupCompleted
          ? "started"
          : "starting",
      });
  },
);

app.get(
  "/health/ready",
  async (request, response) => {
    response.setHeader(
      "Cache-Control",
      "no-store",
    );

    if (shuttingDown) {
      return response
        .status(503)
        .json({
          status: "not_ready",
        });
    }

    try {
      await database.query("SELECT 1");

      return response.status(200).json({
        status: "ready",
      });
    } catch {
      return response
        .status(503)
        .json({
          status: "not_ready",
        });
    }
  },
);

app.get(
  "/metrics",
  async (request, response) => {
    response.setHeader(
      "Content-Type",
      registry.contentType,
    );

    response.send(
      await registry.metrics(),
    );
  },
);

app.get(
  "/api/users/:userId",
  async (request, response, next) => {
    try {
      const user =
        await userRepository.findById(
          request.params.userId,
        );

      if (!user) {
        return response
          .status(404)
          .json({
            error: "not_found",
            message:
              "Пользователь не найден",
            requestId: request.id,
          });
      }

      return response.json({
        id: user.id,
        name: user.name,
      });
    } catch (error) {
      return next(error);
    }
  },
);

app.use(
  (
    error,
    request,
    response,
    next,
  ) => {
    request.log.error(
      {
        error,
        method: request.method,
        path: request.path,
      },
      "Unhandled request error",
    );

    return response
      .status(500)
      .json({
        error: "internal_error",
        message:
          "Не удалось обработать запрос",
        requestId: request.id,
      });
  },
);

const server = app.listen(
  process.env.PORT ?? 3000,
  () => {
    logger.info(
      {
        port:
          process.env.PORT ?? 3000,
      },
      "Application started",
    );
  },
);

process.on("SIGTERM", () => {
  shuttingDown = true;

  logger.info(
    "Graceful shutdown started",
  );

  server.close(async (error) => {
    if (error) {
      logger.error(
        {
          error,
        },
        "HTTP server shutdown failed",
      );

      process.exit(1);
    }

    try {
      await database.close();

      logger.info(
        "Application stopped",
      );

      process.exit(0);
    } catch (shutdownError) {
      logger.error(
        {
          error: shutdownError,
        },
        "Resource shutdown failed",
      );

      process.exit(1);
    }
  });
});

В реальном приложении необходимо дополнительно:


Необработанные ошибки процесса

Необработанное отклонение Promise:

process.on(
  "unhandledRejection",
  (reason) => {
    logger.fatal(
      {
        reason,
      },
      "Unhandled promise rejection",
    );

    initiateShutdown();
  },
);

Необработанное исключение:

process.on(
  "uncaughtException",
  (error) => {
    logger.fatal(
      {
        error,
      },
      "Uncaught exception",
    );

    initiateShutdown();
  },
);

После uncaughtException состояние процесса может быть ненадёжным. Обычно следует:

  1. записать критическое событие;
  2. прекратить принимать запросы;
  3. завершить текущие операции в ограниченный срок;
  4. остановить процесс;
  5. позволить оркестратору запустить новый экземпляр.

Не следует продолжать работу бесконечно после неизвестной ошибки состояния.


Частые ошибки

Неструктурированные строки

Нежелательно:

console.log(
  `Error for user ${userId}: ${error}`,
);

Предпочтительно:

logger.error(
  {
    error,
    userId,
  },
  "User operation failed",
);

Логирование секретов

Нежелательно:

logger.info({
  headers: request.headers,
  body: request.body,
});

Предпочтительно записывать только заранее выбранные безопасные поля.


Одинаковый уровень для всех событий

Нежелательно:

logger.error("Application started");
logger.error("User not found");
logger.error("Database unavailable");

Уровень должен отражать влияние события.


Отсутствие Request ID

Без идентификатора невозможно надёжно связать события параллельных запросов.

Каждый ответ полезно снабжать:

X-Request-ID: req-6c21b86d

И возвращать тот же идентификатор в сообщении об ошибке:

{
  "error": "internal_error",
  "requestId": "req-6c21b86d"
}

Уникальные значения в labels метрик

Нежелательно:

metric.inc({
  userId,
  requestId,
  path: request.originalUrl,
});

Это создаёт большое количество временных рядов.

Уникальные значения должны находиться в логах и трейсах.


Только средняя latency

Среднее значение скрывает медленные запросы.

Нужно анализировать как минимум:

p50
p95
p99

Readiness используется как liveness

Если liveness зависит от базы, временная проблема базы может вызвать бесконечные перезапуски всех экземпляров.

Liveness  — нужно ли перезапустить процесс?
Readiness — можно ли направлять трафик?

Слишком подробный health-check

Нежелательный публичный ответ:

{
  "databaseHost": "db-internal-01",
  "databaseVersion": "16.2",
  "connectionString": "...",
  "lastError": "password authentication failed"
}

Публичный endpoint должен возвращать минимальную информацию.


Синхронная отправка логов

Основной запрос не должен ждать внешнюю систему логирования.

Предпочтительна запись в stdout или неблокирующий локальный агент.


Логирование и метрики без владельца

Телеметрия полезна только тогда, когда известно:


Практический минимальный набор

Для небольшого HTTP API достаточно начать со следующего.

Логи:

JSON-формат
timestamp
level
message
service
environment
requestId
route
statusCode
durationMs
структурированная ошибка

Метрики:

http_requests_total
http_request_duration_seconds
http_requests_in_progress
process_cpu
process_memory
event_loop_lag

Health-check:

GET /health/live
GET /health/ready
GET /health/startup

Трейсинг:

traceId
spanId
W3C trace context
автоматическая HTTP-инструментация

Оповещения:

Высокая доля 5xx
Рост p95/p99 latency
Несколько неготовых экземпляров
Переполнение очереди
Недоступность критической зависимости

Краткая памятка

Три сигнала наблюдаемости:

Логи   — подробности отдельных событий
Метрики — числовое состояние системы
Трейсы — путь запроса между компонентами

Структурированный лог:

logger.info(
  {
    requestId,
    userId,
    orderId,
    durationMs,
  },
  "Order created",
);

Уровни:

trace — максимально подробная диагностика
debug — данные для отладки
info  — нормальное значимое событие
warn  — деградация или потенциальная проблема
error — операция завершилась ошибкой
fatal — приложение не может продолжать работу

Идентификаторы:

requestId — один входящий запрос
traceId   — вся распределённая цепочка
spanId    — отдельная операция

Базовые метрики:

RPS     — частота запросов
Latency — время ответа, включая p50/p95/p99
Errors  — количество и доля ошибок

RED:

Rate
Errors
Duration

Health-check:

/health/live    — процесс жив
/health/ready   — экземпляр готов принимать трафик
/health/startup — запуск завершён

Основные правила: