{"index":268,"slug":"editorial-2020-07-field-structured-logs","title":"Структурированные логи: поля, которым можно доверять","excerpt":"Как связать событие с запросом, удалить чувствительные данные до сериализации и не превратить журнал в набор уникальных строк.","contentHtml":"

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

\n

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

\n

Тезис: структурированный лог — это небольшой контракт события. В нём есть устойчивое имя события, владелец операции, идентификатор связи и ограниченный набор нормализованных полей. Контракт нужно сформировать до сериализации. Тогда поиск использует поля, redaction видит структуру объекта, а каждое добавленное значение можно объяснить.

\n

Что именно делает лог полезным

\n

Сначала назовём вопрос. Например: «какой запрос привёл к отказу адаптера?» Для него нужны request_id, имя события, сервис, маршрут-шаблон, код результата и нормализованный код причины. Полное тело запроса не нужно. Текст исключения тоже не нужен, если в нём нет устойчивого кода, который можно проверить.

\n

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

\n

У остальных полей другой режим. event описывает тип события и должен иметь небольшой словарь. route содержит шаблон, а не конкретный путь с идентификатором заказа. service обозначает владельца границы, а не имя pod или локальный путь. error_code называет известную причину из ограниченного набора. Эти поля подходят для фильтра и группы.

\n
Назначение полей структурированного события
ПолеРольДопустимое значениеЧего не делать
request_idнайти одну историюстабильный идентификатор запросаиспользовать как метрику-группу
eventназвать тип событияadapter.response.rejectedвставлять номер заказа или текст ошибки
routeсравнить обработчики/orders/:orderIdписать полный URL и query string
error_codeразделить причиныSCHEMA_MISMATCHсохранять произвольное сообщение исключения
authorizationне нужна для поиска событияне записывать или заменить на [REDACTED]передавать сырой объект headers
\n

Redaction выполняется до JSON.stringify

\n

Поздняя маскировка ломается на границе строк. Если сначала выполнить JSON.stringify(request), а потом искать секрет регулярным выражением, правило зависит от вложенности, регистра ключа и формата значения. Неожиданный объект легко попадёт в stdout целиком. Маска должна получить объект, пройти известные ключи и только затем передать безопасную копию сериализатору.

\n
const sensitiveKeys = new Set(['authorization', 'cookie', 'password', 'token', 'secret']);\n\nfunction redact(value, key = '') {\n  if (sensitiveKeys.has(key.toLowerCase())) return '[REDACTED]';\n  if (Array.isArray(value)) return value.map((item) => redact(item));\n  if (value && typeof value === 'object') {\n    return Object.fromEntries(\n      Object.entries(value).map(([name, item]) => [name, redact(item, name)]),\n    );\n  }\n  return value;\n}\n\nconst trainingContext = {\n  request_id: 'req-demo-01',\n  event: 'http.request.completed',\n  headers: { authorization: 'synthetic-placeholder' },\n  http: { route: '/orders/:orderId', status_code: 202 },\n};\n\nconsole.log(JSON.stringify(redact(trainingContext)));\n// Учебный пример: placeholder не является реальным секретом.
\n

В результате учебного вызова значение headers.authorization должно стать [REDACTED]. Проверять нужно именно строку, которая уйдёт в поток вывода. Проверка внутреннего объекта недостаточна: код может безопасно изменить копию, а затем случайно залогировать исходную. Также нельзя считать список ключей полной защитой. Интеграция может назвать поле credential, вложить секрет в строку или передать его под другим именем.

\n

Надёжнее не передавать логгеру сырой запрос. Контроллер выбирает метод, шаблон маршрута и код ответа. Адаптер выбирает своё имя и нормализованный код ошибки. Redaction остаётся второй границей, а не разрешением писать любой JSON. Если поле не нужно для конкретного вопроса, его удаляют, а не маскируют «на всякий случай».

\n

Кардинальность определяет качество поиска

\n

Кардинальность — это число разных значений поля. Для event она должна быть низкой. Иначе вместо одного фильтра adapter.response.rejected появятся сотни имён: order.7421.failed, order.7422.failed и так далее. Журнал сохранит все строки, но перестанет давать устойчивую группу. Полный URL, свободный текст исключения и имя пользователя создают ту же проблему.

\n

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

\n
\"Дерево
Поле проходит проверку вопросом «зачем оно нужно?». Если значение не ведёт к проверяемому действию, оно не входит в событие. Иллюстрация показывает учебную схему, а не карту конкретной production-системы.
\n

Один учебный сбой

\n

Ниже приведён синтетический JSONL-пример. Он показывает форму данных, а не результат реального инцидента. Один request_id связывает вход, отказ адаптера и ответ gateway. Внешняя причина сведена к коду. Номер заказа и тело запроса отсутствуют.

\n
{\"timestamp\":\"2020-07-14T09:30:11.001Z\",\"level\":\"info\",\"service\":\"demo-gateway\",\"environment\":\"training\",\"event\":\"http.request.received\",\"request_id\":\"req-demo-01\",\"http\":{\"route\":\"/orders/:orderId\"}}\n{\"timestamp\":\"2020-07-14T09:30:11.024Z\",\"level\":\"warn\",\"service\":\"demo-catalog-api\",\"environment\":\"training\",\"event\":\"adapter.response.rejected\",\"request_id\":\"req-demo-01\",\"adapter\":{\"name\":\"training-inventory\",\"error_code\":\"SCHEMA_MISMATCH\"}}\n{\"timestamp\":\"2020-07-14T09:30:11.042Z\",\"level\":\"info\",\"service\":\"demo-gateway\",\"environment\":\"training\",\"event\":\"http.request.completed\",\"request_id\":\"req-demo-01\",\"http\":{\"status_code\":502}}\n\njq -c 'select(.request_id == \"req-demo-01\")' training.jsonl\n# Учебный запрос: он не доказывает поведение production.
\n

По этой цепочке можно проверить только форму расследования: найти три записи, увидеть известный код и отделить владельца отказа от gateway. Нельзя делать вывод о частоте 502, времени восстановления или работе реального адаптера. Для таких выводов нужны данные конкретной среды и отдельные измерения. Учебный пример полезен тем, что фиксирует минимальный контракт без ложной статистики.

\n

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

\n
Диагностика типичных дефектов журнала
СимптомПричинаПроверкаДействие
Ошибка найдена, но запрос не находитсяrequest_id теряется на HTTP-границесравнить записи клиента и сервиса по одному учебному идентификаторупередавать контекст явно через исходящий клиент
В событии виден токен или cookieсырой объект сериализовали раньше redactionпроверить финальную stdout-строку на запрещённые значениявыбирать поля вручную и маскировать до сериализации
Каждая ошибка создаёт новый eventдинамическое имя содержит id или свободный текстпосчитать варианты event на учебной выборкеоставить устойчивое имя и добавить ограниченный error_code
Группа по сервису постоянно меняетсяв service попали pod, путь или версия процессасравнить значение с владельцем логической границыотделить service от instance и deployment-метаданных
Лог красивый, но JSON не разбираетсястрока собрана вручную или содержит неэкранированный вводпрогнать каждую строку через JSON.parseсериализовать объект штатным JSON-генератором и санитизировать ввод
\n

Порядок внедрения

\n
  1. Сформулировать один диагностический вопрос и границу операции.
  2. Назначить владельца request_id и описать поведение для отсутствующего или невалидного значения.
  3. Составить маленький словарь событий, маршрутов и кодов ошибок. Не добавлять динамику в имена.
  4. Выбрать безопасные поля на каждой границе. Не передавать полный request, headers, body или объект пользователя.
  5. Применить redaction к синтетическому объекту до сериализации и проверить итоговый JSON.
  6. Сделать три связанные учебные записи от двух модулей и найти их по одному request_id.
  7. Проверить отрицательный путь: ошибка обработчика, отсутствующий идентификатор, невалидный заголовок и неожиданный внешний текст.
  8. Зафиксировать критерий готовности в тесте или проверяемой команде, а затем проверить реальные форматы конкретного логгера.
\n

Ограничения

\n

Структурированный лог не создаёт трассировку. Он не показывает дочерние spans, не исправляет рассинхрон часов и не объясняет фоновые задачи, если для них не определён отдельный идентификатор. Для распределённого критического пути понадобится трассировочный контракт.

\n

Redaction защищает только известные формы. Он не распознаёт любой секрет и не заменяет классификацию данных, права доступа, срок хранения и контроль конфигурации. Высокая кардинальность не всегда вредна: уникальный идентификатор полезен для одной истории, но опасен как поле длительной агрегации. Правило зависит от назначения поля.

\n

Критерий готовности проверяемый: каждая учебная строка проходит JSON.parse; три связанные записи находятся по одному request_id; event, service и route не содержат динамических идентификаторов; запрещённые ключи не выходят в исходном виде; отрицательный путь сохраняет нормализованный error_code. Если хотя бы одно условие не выполняется, контракт ещё не готов.

\n

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

"}