Логи и метрики приложения
Наблюдаемость приложения, или observability, — способность понимать внутреннее состояние системы по данным, которые она создаёт во время работы.
Обычно наблюдаемость строится на трёх основных сигналах:
| Сигнал | На какой вопрос отвечает |
|---|---|
| Логи | Что именно произошло? |
| Метрики | Насколько часто и насколько быстро это происходит? |
| Трейсы | Как запрос прошёл через компоненты системы? |
Пример расследования ошибки:
Метрика показывает рост HTTP 500
↓
Трейс находит медленный или ошибочный сервис
↓
Логи объясняют конкретную причину ошибкиЛоги, метрики и трейсы дополняют друг друга, но не заменяют друг друга.
Не следует записывать отдельный лог на каждое числовое измерение, если задачу лучше решает метрика. Также не стоит добавлять в метрику подробный текст исключения — для этого предназначены логи.
Содержание
- Структурированное логирование
- Уровни логирования
- Correlation ID
- Распределённый трейсинг
- Метрики приложения
- RPS
- Latency
- Errors
- RED-метод
- USE-метод
- Метрики в Node.js
- Cardinality метрик
- Бизнес-метрики
- Метрики внешних зависимостей
- Event loop и память Node.js
- Health-check endpoint
- Graceful shutdown
- Интеграция с внешними системами логирования
- Application logs и Audit logs
- Алерты
- SLI, SLO и SLA
- Полный пример Express-приложения
- Необработанные ошибки процесса
- Частые ошибки
- Практический минимальный набор
- Краткая памятка
Структурированное логирование
Логирование — запись событий, произошедших во время работы приложения.
Обычный текстовый лог:
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
Для структурированного логирования часто используются библиотеки, которые умеют:
- сериализовать данные в JSON;
- добавлять базовые поля;
- форматировать ошибки;
- скрывать чувствительные значения;
- создавать дочерние логгеры;
- работать достаточно быстро.
Пример с логгером, поддерживающим структурированные записи:
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 зависит от логирующей библиотеки. Следует проверить, что в журнал действительно попадают:
- тип ошибки;
- сообщение;
- стек вызовов;
- код ошибки;
- причина, если используется
cause.
Логирование ошибок
Стандартный объект 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,
});Такие записи:
- создают большой объём;
- могут раскрыть секреты;
- усложняют поиск;
- увеличивают стоимость хранения;
- добавляют шум.
Что нельзя записывать в логи
Не следует журналировать:
- пароли;
- хеши паролей без необходимости;
- access- и refresh-токены;
- session ID;
- cookie целиком;
- API-ключи;
- OAuth client secret;
- приватные ключи;
- полные данные банковских карт;
- коды восстановления;
- содержимое загруженных документов;
- персональные данные без обоснованной необходимости.
Небезопасно:
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
Контейнерная платформа
↓
Агент или коллектор
↓
Центральное хранилище
↓
Поиск, панели и оповещенияПриложению обычно не нужно самостоятельно:
- управлять ротацией файлов;
- пересылать каждый лог по HTTP;
- поддерживать соединение с системой хранения логов;
- удалять старые файлы.
Эти обязанности лучше передать инфраструктурному агенту или платформе.
Уровни логирования
Уровень показывает важность и назначение события.
Распространённые уровни:
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",
);Примеры:
- запрос завершился статусом
500; - не удалось обработать сообщение;
- внешний сервис вернул ошибку;
- фоновая задача не выполнена;
- транзакция отменена.
Не каждый клиентский ответ 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Распределённый трейсинг связывает операции через:
traceId— идентификатор всего пути;spanId— идентификатор отдельной операции;- parent span — родительскую операцию;
- атрибуты;
- статус;
- длительность;
- события.
Пример:
Trace: checkout
├── Span: POST /orders
├── Span: SELECT products
├── Span: calculate total
├── Span: POST payment-service
│ └── Span: payment-provider request
└── Span: INSERT orderCorrelation 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-7traceId остаётся общим, а локальный requestId может измениться.
Передача контекста
Для межсервисного трейсинга применяется стандартизированный заголовок traceparent:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01Упрощённая структура:
version-traceId-parentSpanId-flagsНе рекомендуется вручную реализовывать полноценную обработку распределённого контекста. Обычно используется библиотека трейсинга, например инструменты экосистемы OpenTelemetry.
OpenTelemetry
OpenTelemetry предоставляет единый подход к:
- трассировкам;
- метрикам;
- контексту;
- инструментированию HTTP;
- экспортированию телеметрии.
Концептуальная ручная операция:
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 полезно анализировать по:
- сервису;
- маршруту;
- HTTP-методу;
- классу статус-кода;
- региону;
- версии приложения.
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_classUSE-метод
Для инфраструктурных ресурсов применяется модель USE:
Utilization — доля использования ресурса
Saturation — накопившаяся очередь или конкуренция
Errors — ошибки ресурсаПримеры:
CPU utilization
Memory usage
Event loop lag
Database connection pool utilization
Queue length
Disk errorsRED больше подходит для пользовательских запросов, 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 метрик не следует без необходимости публиковать в интернете.
Его можно ограничить:
- внутренней сетью;
- отдельным портом;
- network policy;
- аутентификацией;
- reverse proxy;
- allowlist адресов.
Метрики могут раскрывать:
- названия сервисов;
- маршруты;
- версии;
- нагрузку;
- внутренние зависимости;
- рабочее состояние системы.
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",
});Необходимо определить, когда операция считается успешной.
Например:
Заказ записан в базу
или
Заказ оплачен
или
Заказ передан на обработкуНазвание и описание метрики должны отражать точную семантику.
Метрики внешних зависимостей
Полезно измерять обращения к:
- базе данных;
- кэшу;
- очереди сообщений;
- внешним API;
- объектному хранилищу;
- почтовому провайдеру.
Минимальный набор:
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-метрик полезно наблюдать:
- использование heap;
- resident memory;
- загрузку CPU;
- задержку event loop;
- число активных handles;
- частоту и длительность garbage collection;
- число открытых соединений;
- размер пула базы данных.
Большая задержка event loop может указывать на:
- синхронные тяжёлые вычисления;
- обработку крупных JSON;
- синхронную работу с файлами;
- большое количество callback;
- перегрузку процесса;
- длительную сборку мусора.
Стандартный сборщик метрик библиотеки может собирать часть процессных показателей автоматически.
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 обычно проверяет:
- работает ли event loop;
- не находится ли приложение в необратимом состоянии;
- способен ли процесс ответить на простой запрос.
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 не должен раскрывать:
- пароли;
- строки подключения;
- IP внутренних серверов;
- версии базы данных;
- полные тексты исключений;
- секреты;
- подробную топологию инфраструктуры.
Публичный ответ:
{
"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Подходит для:
- полнотекстового поиска;
- фильтрации по полям;
- построения панелей;
- сложного анализа событий.
Следует контролировать:
- количество индексируемых полей;
- cardinality;
- срок хранения;
- размер индексов;
- права доступа;
- персональные данные.
Grafana Loki
Схема:
Application
↓
Collector
↓
Loki
↓
GrafanaLoki ориентирован на хранение и поиск логов с использованием 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),
},
);Проблемы:
- увеличивается latency;
- недоступность лог-системы ломает приложение;
- создаётся дополнительная нагрузка;
- возможна рекурсия ошибок;
- журналы теряются при сетевом сбое.
Предпочтительная схема:
Приложение пишет в 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
Доступность endpointSLO — целевое значение SLI:
99.9% запросов должны завершаться успешно за месяцSLA — соглашение с пользователем или заказчиком, которое может включать последствия нарушения.
Пример SLI доступности:
успешные запросы / все допустимые запросыПример SLO:
Не менее 99.95% успешных запросов за 30 днейНеобходимо точно определить, какие запросы считаются:
- входят ли
4xx; - учитываются ли плановые работы;
- какие маршруты относятся к SLO;
- как обрабатываются ошибки клиента;
- за какой период считается показатель.
Полный пример 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);
}
});
});В реальном приложении необходимо дополнительно:
- ограничить доступ к
/metrics; - добавить тайм-аут readiness-проверки;
- избежать двойного уменьшения активных запросов;
- настроить трейсинг;
- добавить корректное завершение очередей и фоновых задач;
- настроить обработку необработанных ошибок;
- определить политику логирования
4xx; - проверить маскирование чувствительных данных.
Необработанные ошибки процесса
Необработанное отклонение Promise:
process.on(
"unhandledRejection",
(reason) => {
logger.fatal(
{
reason,
},
"Unhandled promise rejection",
);
initiateShutdown();
},
);Необработанное исключение:
process.on(
"uncaughtException",
(error) => {
logger.fatal(
{
error,
},
"Uncaught exception",
);
initiateShutdown();
},
);После uncaughtException состояние процесса может быть ненадёжным. Обычно следует:
- записать критическое событие;
- прекратить принимать запросы;
- завершить текущие операции в ограниченный срок;
- остановить процесс;
- позволить оркестратору запустить новый экземпляр.
Не следует продолжать работу бесконечно после неизвестной ошибки состояния.
Частые ошибки
Неструктурированные строки
Нежелательно:
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
p99Readiness используется как liveness
Если liveness зависит от базы, временная проблема базы может вызвать бесконечные перезапуски всех экземпляров.
Liveness — нужно ли перезапустить процесс?
Readiness — можно ли направлять трафик?Слишком подробный health-check
Нежелательный публичный ответ:
{
"databaseHost": "db-internal-01",
"databaseVersion": "16.2",
"connectionString": "...",
"lastError": "password authentication failed"
}Публичный endpoint должен возвращать минимальную информацию.
Синхронная отправка логов
Основной запрос не должен ждать внешнюю систему логирования.
Предпочтительна запись в stdout или неблокирующий локальный агент.
Логирование и метрики без владельца
Телеметрия полезна только тогда, когда известно:
- кто следит за панелью;
- какие значения считаются нормальными;
- при каких условиях создаётся alert;
- кто реагирует;
- где находится инструкция по устранению проблемы.
Практический минимальный набор
Для небольшого 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_lagHealth-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
DurationHealth-check:
/health/live — процесс жив
/health/ready — экземпляр готов принимать трафик
/health/startup — запуск завершёнОсновные правила:
- записывайте логи в структурированном JSON;
- используйте единые имена полей;
- добавляйте
requestIdко всем событиям запроса; - связывайте логи с
traceIdиspanId; - не записывайте пароли, токены, cookie и ключи;
- маскируйте персональные и чувствительные данные;
- выбирайте уровень по влиянию события;
- измеряйте RPS, ошибки и распределение latency;
- анализируйте
p95иp99, а не только среднее; - не используйте уникальные ID как labels метрик;
- разделяйте liveness, readiness и startup;
- не проверяйте внешние зависимости в liveness без необходимости;
- ограничивайте доступ к подробным health-check и
/metrics; - пишите логи в stdout и собирайте их инфраструктурным агентом;
- не делайте основную работу зависимой от доступности лог-хранилища;
- настраивайте срок хранения, права доступа и удаление данных;
- создавайте alerts только для ситуаций, требующих действия;
- сочетайте логи, метрики и трейсы при расследовании ошибок.