{ "index": 270, "slug": "editorial-2020-07-practice-structured-logs", "title": "Структурированные логи: как связать один запрос и быстро найти ошибку", "excerpt": "Контракт JSON-события, единый request_id и проверка отрицательного пути помогают найти запрос без поиска по случайному тексту. Разбираем форму записи, границы корреляции и redaction.", "contentHtml": "

В журнале появляются три строки: «ошибка оплаты», «запрос завершён» и стек исключения. Все записи стоят рядом, но неизвестно, относятся ли они к одному запросу. Инженер ищет по минуте, маршруту и фрагменту текста. Он легко связывает чужие события. Цена ошибки — неверная правка, повторный инцидент и потерянное время во время сбоя.

\n

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

\n

Рабочий минимум — контракт одного JSON-события и один request_id на входное HTTP-действие. Обязательные поля имеют постоянные имена. Локальный модуль добавляет только свой контекст. Такой журнал отвечает на узкий вопрос: какие известные события принадлежат этому запросу? Он не заменяет распределённую трассировку и не доказывает причинность.

\n

Из чего состоит событие

\n

Структурированный лог — это объект, а не строка, которую потом приходится разбирать регулярным выражением. Человек читает короткое поле message. Поиск и агрегация используют event, service, уровень и идентификатор запроса. JSON сам по себе ничего не гарантирует. Поля становятся полезными только тогда, когда команда закрепила их смысл и форму.

\n
Минимальный контракт учебного события
ПолеКто задаётФормаВопрос
timestampлоггерUTC ISO-8601Когда произошло событие?
levelоперацияdebug, info, warn, errorНасколько срочно его смотреть?
serviceконфигурацияустойчивое имяГде оно произошло?
environmentконфигурациянапример, trainingНе смешаны ли контуры?
eventвладелец операцииnoun.verbЧто произошло?
request_idHTTP-границаодно проверенное значениеКакие записи относятся к запросу?
messageвладелец операциикороткий текстЧто увидит читатель?
\n

Общие поля живут в корне объекта. Контекст операции — во вложенном блоке http, job или adapter. Не передавайте в логгер целиком req, ответ базы или объект пользователя. Форма такого объекта меняется без предупреждения. В ней могут оказаться заголовки, тело запроса и секреты. Выберите несколько полей, которые закрывают конкретный вопрос.

\n
\"Схема
Общий контракт остаётся коротким, а локальный HTTP-контекст находится в отдельном блоке. Учебная иллюстрация показывает форму записи, а не результат production-наблюдения.
\n

Собираем запись до вывода

\n

Функция формирования события должна принимать только известный контекст. Она проверяет обязательные ключи до сериализации. Это не универсальная библиотека и не схема всей системы. В учебном примере время и идентификатор заданы явно, чтобы результат можно было повторить. В рабочем приложении логгер обычно добавляет время сам.

\n
const required = [\n  'timestamp', 'level', 'service', 'environment',\n  'event', 'request_id', 'message'\n];\n\nfunction buildEvent(base, local) {\n  const event = { ...base, ...local };\n  const missing = required.filter((name) => event[name] === undefined);\n  if (missing.length) {\n    throw new Error('missing log fields: ' + missing.join(', '));\n  }\n  return event;\n}\n\nconst entry = buildEvent(\n  {\n    timestamp: '2020-07-14T09:30:11.042Z',\n    level: 'info',\n    service: 'demo-catalog-api',\n    environment: 'training',\n    event: 'http.request.completed',\n    request_id: 'req-demo-20200714-01',\n    message: 'Synthetic request completed'\n  },\n  { http: { method: 'POST', route: '/training/orders/:orderId', status_code: 202 } }\n);\n\nprocess.stdout.write(JSON.stringify(entry) + '\\n');
\n

route здесь — шаблон, а не полный URL с номером заказа. Так сто заказов не создают сто новых значений маршрута. Имя event тоже выбирают из небольшого словаря: http.request.received, order.validation.failed, http.request.completed. Не включайте в имя номер ошибки, текст исключения или идентификатор пользователя. Поле перестанет быть пригодным для группировки.

\n

Проверка формы и проверка цепочки — разные проверки. Первая смотрит один объект: есть ли ключи, допустим ли уровень, замаскированы ли чувствительные значения. Вторая смотрит несколько событий: совпадает ли request_id, есть ли ожидаемые начало и завершение, не пропал ли контекст на границе сервиса. Если первая проверка падает, исправляют builder. Если вторая — место передачи контекста.

\n

Один синтетический запрос

\n

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

\n
{\"timestamp\":\"2020-07-14T09:30:11.001Z\",\"level\":\"info\",\"service\":\"demo-gateway\",\"environment\":\"training\",\"event\":\"http.request.received\",\"request_id\":\"req-demo-20200714-01\",\"message\":\"Synthetic request accepted\"}\n{\"timestamp\":\"2020-07-14T09:30:11.021Z\",\"level\":\"info\",\"service\":\"demo-catalog-api\",\"environment\":\"training\",\"event\":\"order.validation.completed\",\"request_id\":\"req-demo-20200714-01\",\"message\":\"Synthetic order passed validation\"}\n{\"timestamp\":\"2020-07-14T09:30:11.042Z\",\"level\":\"info\",\"service\":\"demo-gateway\",\"environment\":\"training\",\"event\":\"http.request.completed\",\"request_id\":\"req-demo-20200714-01\",\"message\":\"Synthetic request completed\"}\n\n# Учебный поиск в JSONL, не production-команда:\njq -c 'select(.request_id == \"req-demo-20200714-01\")' training.jsonl
\n

Ожидаемый результат содержит три записи и один идентификатор. Если запись API отсутствует, проверяют передачу контекста и наличие события в этом модуле. Если API пишет другой идентификатор, ищут вторую генерацию на исходящем вызове. Если завершение повторяется, проверяют повторный вызов обработчика. Эти выводы ограничены учебным набором. По ним нельзя утверждать, что все реальные сервисы системы пишут логи.

\n

Симптом → причина → проверка → действие

\n
Короткий маршрут диагностики
СимптомПричинаПроверкаДействие
Ошибка есть, но запрос не найтиНет общего request_idСравнить обязательные поля у соседних событийНазначить владельца идентификатора на HTTP-входе
Один модуль виден, второй нетКонтекст не передан через HTTP-клиентПроверить заголовок и событие на обеих сторонахПередавать контекст явно в сигнатуре адаптера
JSON валиден, поиск ломаетсяrequestId вместо request_id или свободное имя событияПроверить ключи и словарь событийИсправить builder и добавить проверку схемы
Лог содержит токен или cookieСериализовали сырой объект запросаПроверить запись до stdout и fixture redactionИсключить поле или заменить значение до JSON
События рядом по времени, но связь не доказанаВремя ошибочно приняли за корреляциюНайти общий идентификатор в каждой записиНе делать вывод без ключа; для причинности нужна трассировка
\n

Не теряем request_id на границах

\n

Идентификатор создаёт или принимает первая HTTP-граница. Она проверяет длину и допустимые символы. Внутренний формат учебного примера — req-demo-YYYYMMDD-NN. В рабочем коде формат может быть другим, но владельцем остаётся один слой. Клиентский заголовок нельзя без проверки копировать в журнал: он может быть слишком длинным, содержать управляющие символы или использоваться для загрязнения поиска.

\n

После входа создают дочерний логгер с общими полями и передают его в следующий модуль. Не храните текущий идентификатор в глобальной переменной. Два параллельных запроса перезапишут значение. Не генерируйте новый ключ в адаптере. Иначе каждая запись будет выглядеть аккуратно, но цепочка распадётся.

\n

Обработчик ошибки должен использовать тот же контекст. Частая ошибка — создать новый логгер внутри catch и записать стек без request_id. Для диагностики это самое дорогое место: именно важное событие выпадает из поиска. Сериализуйте нормализованный код ошибки и короткое сообщение. Полный объект исключения может содержать запрос, заголовки и данные внешней системы.

\n

Фоновая задача требует отдельного решения. Она может продолжать исходное действие, а может быть новым действием. Механически копировать request_id нельзя: это создаёт ложную непрерывную трассу. Если связь нужна, храните её как явную ссылку, а для попытки используйте отдельный operation_id. В этой статье фоновая очередь не моделируется.

\n

Что нельзя смешивать

\n

request_id полезен для поиска одной истории. Он высококардинален и плохо подходит для графика. event и service имеют ограниченный словарь и подходят для подсчёта повторяющихся случаев. Не превращайте уникальный идентификатор в имя метрики или тег каждого агрегата.

\n

Корреляция не равна причинности. Одинаковый ключ показывает принадлежность одному входному действию, но не показывает точный порядок параллельных операций. Часы машин могут расходиться. Отложенная запись может появиться позже. Если нужен критический путь, дочерние операции или межсервисные задержки, нужна отдельная модель трассировки. Сортировка строк по времени эту модель не создаёт.

\n

Redaction выполняют до сериализации. Маска в интерфейсе просмотра недостаточна: секрет уже мог попасть в файл, транспорт или резервную копию. Минимальный список для отдельной проверки — authorization, cookie, password, token и secret. Реальный список зависит от приложения и должен жить рядом с кодом формирования записи.

\n

Порядок действий

\n
  1. Выберите один HTTP-маршрут и назовите владельца request_id.
  2. Зафиксируйте обязательные поля и словарь из нескольких имён событий.
  3. Добавьте валидатор идентификатора и явное поведение для отсутствующего или неверного входного значения.
  4. Создайте дочерний логгер на входе и передайте контекст через исходящий клиент.
  5. Соберите три синтетические записи от двух модулей с одним ключом.
  6. Проверьте форму каждого объекта и поиск всей цепочки в JSONL.
  7. Добавьте redaction до сериализации и отдельную отрицательную проверку для секретного поля.
  8. Только после успешной проверки перенесите контракт на соседний маршрут.
\n

Ограничения и критерий готовности

\n

Минимальный контракт не показывает работу сервиса, полноту журнала, доставку записи, задержку коллектора или реальный пользовательский эффект. Учебный fixture не читает production-логи, не отправляет сеть и не устанавливает причину инцидента. Синтетические значения нельзя выдавать за измеренные результаты.

\n

Практика готова, когда разрешённый учебный запрос возвращает все ожидаемые события по одному request_id; каждый объект проходит проверку обязательных полей; отрицательный тест обнаруживает потерю или замену ключа; redaction не выпускает заданные чувствительные значения; команда может назвать границу, на которой нужно искать пропажу контекста. Если хотя бы один пункт не выполнен, контракт ещё не даёт проверяемой диагностики.

\n

Проверяемые источники

\n"}