Наблюдаемость с нуля: correlation-id, структурные логи и единый формат событий
Соберём минимальный стандарт логирования для API, добавим корреляцию между сервисами и научимся читать логи как граф запросов. Дадим чек-лист того, что логировать, а что нет.
Содержание
Наблюдаемость с нуля: correlation-id, структурные логи и единый формат событий
Наблюдаемость (observability) часто обещают как магию: «смотри логи — и всё станет понятно». На практике же команды упираются в три типичные проблемы: логи превращаются в поток текста без структуры, запросы теряются между сервисами, а корреляция «кто вызвал кого» восстанавливается вручную по разным признакам. В итоге система не становится прозрачнее — она становится сложнее.
В этой статье соберём минимальный, но практичный стандарт логирования для API: как ввести correlation-id, как перейти от текстовых сообщений к структурным логам и как договориться об едином формате событий, чтобы логи читались как граф запросов между сервисами. Параллельно разберём, что логировать стоит, а что — нет: это ключ к качеству и к экономике хранения/индексации.
Материал будет полезен, если вы только начинаете наблюдаемость, а также если уже «что-то логируете», но не чувствуете контроля над цепочками вызовов.
Почему логи перестают помогать: три системные причины
Логи как строки: сложно анализировать
Большинство проблем начинается с формы: logger.info("user login failed: " + err) — это строка. При этом в системе почти всегда есть атрибуты, которые нужны для фильтрации и анализа: userId, ip, errorCode, route, latency, serviceName. Когда они не выделены как поля, вы теряете возможность эффективно запросить/агрегировать данные в поиске или в аналитике.
Нет корреляции: вы видите части, но не связь
В микросервисах один запрос превращается в серию вызовов: API → сервис авторизации → сервис заказов → БД и так далее. Без коррелятора вы будете каждый раз восстанавливать цепочку вручную, подбирая похожие времена и IP. Это не «наблюдаемость», это расследование.
Нет единого формата событий: вы вынуждены угадывать
Если разные сервисы логируют по-разному — разные поля, разные названия, разные уровни логирования — то единый дашборд превращается в набор хака. Даже простая выгрузка «все ошибки по userId» превращается в ручную работу.
Минимальный стандарт логирования для API: состав, правила, формат
Цель — не построить идеальный observability stack, а добиться результата «в один вечер»: чтобы любой запрос можно было отследить по логам как цепочку событий, и чтобы это не требовало знаний конкретной реализации.
Что логируем: события как контракт
С точки зрения операционной поддержки полезно мыслить не «логировать всё», а «фиксировать события жизненного цикла запроса и бизнеса». Минимальный набор для API:
- HTTP вход: старт обработки запроса (метаданные запроса).
- HTTP выход: завершение с кодом ответа, временем, итоговым статусом.
- Бизнес-ошибка: ожидаемые ошибки домена (например, «карта просрочена», «недостаточно прав»).
- Техническая ошибка: неожиданные исключения (таймауты, падения, ошибки интеграций).
- Внешние вызовы (если у вас микросервисы или интеграции): запрос к соседнему сервису/БД/очереди с результатом и latency.
- Решения/ветвления (по желанию, но крайне полезно): например, «выбрали стратегию A вместо B» или «фича включена».
Ключевой принцип: каждое событие должно быть машиночитаемым и связанным с контекстом запроса.
Что добавляем в каждый лог-сообщение: поля контекста
Чтобы «логи читались как граф запросов», вводим набор полей, которые в идеале присутствуют во всех сервисах:
timestamp— время (обычно ISO-8601 в UTC).level— уровень (debug/info/warn/error).service— имя сервиса.env— окружение (prod/stage/dev).traceIdилиcorrelationId— идентификатор корреляции.spanId(если используете tracing) — необязательно для старта, но полезно.event— тип события (например,http.request_started,http.request_finished).method,path— для HTTP.statusCode— для ответа.durationMs— длительность обработки.requestId(если у вас есть отдельный ID от сервера/балансировщика) — опционально.userId/clientId— если это бизнес-атрибут и безопасно.error— структурированный объект сcode,message,type(и без утечек).tags— массив/объект для произвольных меток (редко и аккуратно).
Важный нюанс: не пытайтесь логировать слишком много. В наблюдаемости лучше иметь стабильный минимальный набор и жёсткие правила приватности, чем «всё подряд», а потом не иметь возможности искать.
correlation-id: что это и чем отличается от traceId
correlation-id (иногда в командах его называют traceId) — это идентификатор, который вы прокидываете сквозь границы сервисов. Его главная задача: по одному значению вы можете восстановить путь запроса.
Если вы позже перейдёте к полноценному distributed tracing (OpenTelemetry), traceId и spanId станут частью стандарта. Но correlation-id можно внедрить быстрее: он не требует немедленно полного tracing backend.
Единый формат событий: один JSON в строке
Практика: лог-сообщение должно быть одним JSON-объектом в строке. Так проще интегрироваться с лог-агрегатором (ELK/OpenSearch, Loki, Datadog logs и т.д.) и проще проверять схемы.
Пример «целевого» JSON-формата события:
{
"timestamp": "2026-07-25T10:12:34.567Z",
"level": "info",
"env": "prod",
"service": "api-gateway",
"correlationId": "5c2f1b2a6f9e4c8f",
"event": "http.request_finished",
"method": "POST",
"path": "/v1/orders",
"statusCode": 201,
"durationMs": 87,
"clientIp": "203.0.113.10",
"userId": "u_193847"
}
Обратите внимание: поля типизированы (числа, строки, объекты), а не «всё в message».
correlation-id сквозь API и сервисы: как внедрить без магии
Источник correlation-id: заголовок запроса
Обычно корреляцию задают через HTTP-заголовок. Часто используют X-Correlation-Id, иногда — traceparent из W3C Trace Context.
Для минимума начните с собственного заголовка:
- При входе в API: если клиент прислал
X-Correlation-Id, используйте его. - Если нет: сгенерируйте новый.
- При вызове других сервисов: прокиньте заголовок дальше.
Критично: выбирайте один заголовок и придерживайтесь его во всей системе.
Генерация ID и контекст потока
В Node.js, Java и Go есть разные способы хранить контекст запроса (AsyncLocalStorage, ThreadLocal, context.Context). Суть одна: correlation-id должен быть доступен в логгере в любой точке обработки запроса.
Ниже пример для Node.js (минимально, но рабоче).
Пример: корреляция в Express + структурные логи
import express from "express";
import crypto from "crypto";
const app = express();
// Простейшая генерация correlation-id
function newCorrelationId() {
return crypto.randomBytes(8).toString("hex"); // 16 hex chars
}
// Ассинхронный контекст
import { AsyncLocalStorage } from "async_hooks";
const als = new AsyncLocalStorage();
function logger(eventObj) {
const ctx = als.getStore() || {};
// Гарантируем наличие correlationId в каждом лог-сообщении
const correlationId = eventObj.correlationId || ctx.correlationId;
const logLine = {
timestamp: new Date().toISOString(),
level: eventObj.level ?? "info",
service: process.env.SERVICE_NAME ?? "api",
env: process.env.NODE_ENV ?? "dev",
correlationId,
...eventObj,
};
// Один JSON в строку
process.stdout.write(JSON.stringify(logLine) + "\n");
}
app.use((req, res, next) => {
const correlationId = req.header("X-Correlation-Id") || newCorrelationId();
const store = { correlationId };
const started = Date.now();
logger({
level: "info",
event: "http.request_started",
method: req.method,
path: req.originalUrl,
// Берём только то, что безопасно логировать
clientIp: req.ip,
});
als.run(store, () => {
res.on("finish", () => {
logger({
level: res.statusCode >= 500 ? "error" : "info",
event: "http.request_finished",
method: req.method,
path: req.originalUrl,
statusCode: res.statusCode,
durationMs: Date.now() - started,
// Можно добавить userId из auth-мидлвари (если туда сохраняете в req)
userId: req.user?.id,
});
});
next();
});
});
// Эмуляция обработки
app.post("/v1/orders", async (req, res) => {
try {
// Пример: бизнес-ошибка
if (!req.body?.itemId) {
logger({
level: "warn",
event: "business.error",
error: {
code: "ORDER_ITEM_MISSING",
message: "itemId is required",
},
});
return res.status(400).json({ error: "ORDER_ITEM_MISSING" });
}
// Пример: успешный ответ
res.status(201).json({ ok: true });
} catch (e) {
logger({
level: "error",
event: "technical.error",
error: {
code: "UNHANDLED_EXCEPTION",
message: e.message,
type: e.name,
},
});
res.status(500).json({ error: "INTERNAL_ERROR" });
}
});
app.listen(3000, () => console.log("listening on 3000"));
Что важно в коде:
- correlation-id хранится в контексте запроса (
AsyncLocalStorage), чтобы логгер всегда мог его подмешать. - мы логируем start и finish события с
durationMs. - ошибки логируются структурировано, с кодом, типом и сообщением (без стека наружу в production, если нет необходимости).
Прокидывание correlation-id при вызовах других сервисов
Дальше — внешние вызовы. Для API → сервис заказов → сервис платежей нужно пробросить тот же X-Correlation-Id.
Пример (условный fetch):
async function callOrdersService({ correlationId, payload }) {
const response = await fetch("http://orders/v1/internal/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Correlation-Id": correlationId,
},
body: JSON.stringify(payload),
});
return response;
}
// Использование внутри хэндлера:
app.post("/v1/payments", async (req, res) => {
const ctx = als.getStore();
const correlationId = ctx.correlationId;
const t0 = Date.now();
logger({ level: "info", event: "external.call_started", target: "orders", url: "/v1/internal/orders" });
try {
const r = await callOrdersService({ correlationId, payload: req.body });
logger({
level: "info",
event: "external.call_finished",
target: "orders",
statusCode: r.status,
durationMs: Date.now() - t0,
});
if (!r.ok) {
return res.status(502).json({ error: "ORDERS_SERVICE_ERROR" });
}
res.status(200).json({ ok: true });
} catch (e) {
logger({
level: "error",
event: "external.call_failed",
target: "orders",
durationMs: Date.now() - t0,
error: { code: "NETWORK_ERROR", message: e.message, type: e.name },
});
res.status(502).json({ error: "BAD_GATEWAY" });
}
});
Смысл: вы видите граф не только на уровне HTTP API, но и на уровне интеграций.
Структурные логи: как сделать формат устойчивым
Почему «структурно» ≠ «просто JSON»
Люди часто делают первый шаг: logger.info({ msg: "..." , userId }). Но если в разных местах поля называются по-разному (user_id vs userId), или местами логгер добавляет разные структуры ошибок — вы теряете преимущества.
Устойчивость достигается договорённостью:
- единые имена полей;
- единые типы значений;
- единые
event-категории; - единая форма ошибок.
Схема ошибки: один стандарт на весь домен
Рекомендуемый минимальный формат:
error.code— короткий, стабильный идентификатор (удобен для статистики).error.message— человекочитаемое сообщение (не обязательно отличающееся от code).error.type— имя класса/категории (опционально).error.details— объект с дополнительными данными (опционально и безопасно).
Пример:
{
"event": "technical.error",
"error": {
"code": "TIMEOUT_UPSTREAM",
"message": "orders request timed out after 5000ms",
"type": "TimeoutError"
}
}
Важно: не включайте stack trace в каждый прод лог в проде. Он раздувает стоимость и мешает машинной обработке. Стек уместен точечно (sampling или только при определённых условиях).
Уровни логирования: прагматичный подход
info: вход/выход запросов, важные состояния без ошибок (start/finish, external call finished).warn: ожидаемые проблемы, которые не должны ломать сервис целиком (валидация, бизнес-отказ, деградация).error: ошибки, которые требуют вмешательства или указывают на сбои (технические исключения, 5xx, failed external call).
debug оставьте для локальной диагностики или sampling. В проде debug часто становится «пылесосом» и дорогой помойкой.
Как читать логи как граф запросов: методика
Когда у вас есть correlationId, чтение становится более инженерным: вы не ищете «по тексту», вы строите маршрут.
Шаг 1: выберите correlationId и посмотрите все события
Типичный workflow:
- Найдите один проблемный запрос: например, 500 на
/v1/orders. - Скопируйте
correlationIdиз логовhttp.request_finished(или из ответа/headers, если вы возвращаете его клиенту). - Запросите все события с этим
correlationIdв лог-агрегаторе.
В идеале вы увидите последовательность:
http.request_startedв APIexternal.call_started→external.call_finishedк соседнему сервисуtechnical.errorилиbusiness.errorhttp.request_finishedс 500/4xx
Шаг 2: восстановите причинность по времени
Даже без полноценного trace вы сможете увидеть:
- где выросла latency (разница
durationMs). - где произошла остановка (последнее событие перед ошибкой).
- повторения (например, несколько внешних вызовов в цикле).
Шаг 3: проверьте согласованность формата
Иногда вы думаете, что «корреляция не работает», но на деле:
- один сервис логирует под другим именем поля (
correlationIDvscorrelationId); - один сервис не прокидывает заголовок;
- лог-агрегатор парсит JSON неправильно.
Самопроверка: пройдитесь по документации формата в нескольких сервисах и проверьте, что поля действительно извлекаются как отдельные параметры.
Чек-лист: что логировать, а что нет
Этот раздел важен не меньше, чем техническая реализация. Логирование — это расходы и риск. Стабильный стандарт должен заранее отвечать на вопросы «как не утонуть» и «как не утечь».
Что логировать обязательно
- HTTP start/finish для каждого входящего запроса в API:
- method, path, статус, durationMs, correlationId.
- Внешние вызовы:
- target сервис/зависимость, статус результата, durationMs.
- Ошибки:
- бизнес-ошибки с
error.code(для аналитики отказов); - технические с
error.codeи категорией (для инцидентов).
- бизнес-ошибки с
- События, влияющие на поведение:
- выбор стратегии, фича-флаг, режим, версию контракта (по делу, не всегда).
Что логировать выборочно
- Параметры запроса (body/query):
- только безопасные поля;
- либо редактирование/маскирование (PII, токены, номера карт).
- userId/clientId:
- если это помогает найти влияние на конкретных пользователей и нет приватности проблем.
- Полезные подсказки для расследования:
- например,
orderIdпри ошибке создания
- например,
Комментарии
Пока нет комментариев