From 720e0282780d71437f8506870f0dfd1f603ffa38 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 21:55:58 +0300 Subject: [PATCH] Editorial: refine structured logs article 268 --- editorial/agent-rewrites/268.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editorial/agent-rewrites/268.json b/editorial/agent-rewrites/268.json index 4150382..823f0ea 100644 --- a/editorial/agent-rewrites/268.json +++ b/editorial/agent-rewrites/268.json @@ -1 +1 @@ -{"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

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

"} +{"index":268,"slug":"editorial-2020-07-field-structured-logs","title":"Разбор журнала: redaction и кардинальность до первой аварии","excerpt":"Выбираем поля для диагностики, удаляем чувствительные значения до сериализации и не превращаем event или route в бесконечный словарь.","contentHtml":"

Проблема заметна поздно: нужное событие найдено, но рядом лежит целый объект запроса, длинный URL и строка, похожая на credential. Другой поиск не работает, потому что имя события каждый раз включает номер заказа или текст внешней ошибки. Цена — не только неудобная диагностика. Журнал становится местом, куда утекают лишние данные, а одинаковые случаи невозможно посчитать или сравнить без ручной очистки.

\n

Лечить это после первого серьёзного сбоя дорого: код уже привык писать «всё, что есть под рукой», а доступ к журналу обычно шире доступа к исходному запросу. Поэтому правила redaction и кардинальности нужны в точке формирования события. В этой статье нет настоящих инцидентов, секретов или production-логов: только синтетическая запись и локальный fixture. Задача — научиться отличать поле, нужное для ответа, от поля, которое журналу не принадлежит.

\n

Разделяем данные на три класса до вызова логгера

\n

Первый класс — безопасный операционный контекст. Это время, уровень, сервис, имя события, шаблон маршрута, код ответа и request_id. Они помогают связать одну историю и не требуют выгружать тело запроса. Второй класс — ограниченный контекст: технический код внешней ошибки, нормализованное состояние, короткое имя адаптера. Его добавляют, если есть точный вопрос диагностики и форма значения известна. Третий класс — запрещённый или redacted: учётные данные, cookies, заголовок authorization, пароль, полный body и произвольный объект пользователя.

\n

Разделение не означает, что второй класс всегда безопасен. Даже поле error.message может включить в себя входные данные внешней системы. Поэтому практический контракт предпочитает error.code и собственное имя события, а текст оставляет коротким и контролируемым. Если подробность действительно нужна, её нельзя добавлять в общий JSON по умолчанию: сначала определяется доступ, срок хранения и отдельный путь проверки. В этой минимальной схеме подробность отсутствует, а не прячется в красивом имени поля.

\n
Политика полей для учебного JSON-события
КлассПримерыЗачем они нужныДействие перед записью
Базовыйtimestamp, service, event, request_idсвязать и прочитать один запросписать в устойчивом формате
Ограниченныйadapter.name, error.code, http.status_codeотделить известную ветвь отказадобавлять только владельцем операции
Redactauthorization, cookie, password, tokenне нужны для поиска событиязаменить до JSON или не передавать объект
Не логироватьbody, полный URL, целый объект пользователяслучайная форма и лишние данныезаменить на шаблон маршрута или нормализованный код
\n
\"Вертикальное
Поле проходит через вопрос «зачем оно нужно?». Если ответ не приводит к проверяемому действию, значение не попадает в событие. Redaction — защита для известных путей, а не разрешение логировать весь объект.
\n

Redaction должен работать с объектом, а не с готовой строкой

\n

Ошибка redaction часто начинается с позднего шага: код уже сделал JSON.stringify(req), затем регулярное выражение пытается вычеркнуть секрет из текста. Вложенный ключ, другой регистр или неожиданная форма легко проходят мимо такой маски. Надёжнее принять маленький объект контекста, пройти его по известным ключам и только потом сериализовать. Документация Pino описывает redaction путями полей; это тот же принцип: правило должно знать структуру, а не угадывать фрагмент строки.

\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(Object.entries(value).map(([name, item]) => [name, redact(item, name)]));\n  }\n  return value;\n}\n\nconst trainingContext = {\n  request_id: 'req-demo-20200714-01',\n  event: 'http.request.completed',\n  headers: { authorization: 'synthetic-placeholder-not-a-secret' },\n  http: { route: '/training/orders/:orderId', status_code: 202 }\n};\nprocess.stdout.write(JSON.stringify(redact(trainingContext)) + "\\n");\n// authorization is emitted as [REDACTED]; no real credential is present in this fixture.
\n

Этот пример не обещает поймать каждый секрет во всех структурах. Он фиксирует минимум: известные чувствительные ключи не должны дойти до stdout в исходном виде. В реальном проекте список расширяется по фактическим форматам интеграций, а тесты добавляют каждый уже известный путь. Если библиотека поддерживает redaction конфигурацией, правило всё равно следует проверять на том JSON, который действительно выходит из процесса. Название опции — не доказательство результата.

\n

Есть и более простая защита: не давать логгеру сырой объект. Контроллер сам выбирает method, route и status_code; адаптер сам выбирает adapter.name и error.code. Тогда redaction остаётся страховочной сеткой, а не единственным барьером. Чем меньше произвольных объектов пересекает границу логирования, тем легче редактору и ревьюеру увидеть, откуда появилось поле.

\n

Кардинальность — это форма будущего поиска

\n

Кардинальность показывает, сколько разных значений может иметь поле. У event она должна быть низкой: десятки устойчивых имён, а не одна новая строка на исключение. У route тоже низкая: шаблон /training/orders/:orderId, а не конкретный путь. У request_id она намеренно высокая, потому что его задача — найти одну историю. Ошибка начинается, когда все эти поля используют одинаково — например, группируют по request_id или добавляют номер заказа в имя события.

\n

Не надо превращать эту заметку в рассказ о зрелой платформе метрик. Здесь достаточно заранее записать, какие поля допускаются как фильтр одного случая, а какие могут быть группой для короткого локального отчёта. Даже если команда пока читает JSONL через jq, это решение уже меняет качество диагностики: следующий разработчик не создаст сто вариантов события ради удобства одного сообщения.

\n
Кардинальность и назначение поля
ПолеОжидаемая кардинальностьРазрешённое использованиеАнтипаттерн
eventнизкаяфильтр и небольшая группаorder.7421.failed
serviceнизкаяграница владельцаимя с номером pod или локальным путём
routeнизкаясравнить обработчикиполный URL с id и query string
request_idвысокаянайти одну историюиспользовать как измерение агрегата
error.codeограниченнаяразделить известные причиныписать произвольный текст исключения
\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","http":{"route":"/training/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-20200714-01","message":"Synthetic adapter response rejected","adapter":{"name":"training-inventory","error_code":"TRAINING_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-20200714-01","message":"Synthetic request completed","http":{"status_code":502}}\n\n# Учебная проверка: поле request_id связывает записи; error_code называет нормализованную причину.\njq -c 'select(.request_id == "req-demo-20200714-01")' training.jsonl
\n

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

\n

Проверяем redaction на форме, которая реально уходит в stdout

\n

Маска полезна только там, где объект окончательно превращается в JSON. Поэтому fixture должен проверять не внутренний JavaScript-объект, а результат сериализации: в нём есть обязательные безопасные поля, а вместо учебного placeholder в headers.authorization стоит ровно [REDACTED]. Такая проверка ловит порядок операций. Если разработчик случайно записал исходный контекст раньше, чем вызвал redaction, тест видит запрещённое значение в строке и не даёт считать код защищённым только по названию функции.

\n

У redaction есть предел. Он не исправит поле, которое автор назвал access вместо token, и не решит, что делать с вложенной строкой, куда внешний сервис уже склеил данные. Поэтому список ключей — не замена ревью. Ревью задаёт два вопроса: откуда взялось значение и можно ли ответить на диагностический вопрос без него? Если второй ответ «да», поле удаляется. Если «нет», для него фиксируется нормализованная форма, путь redaction и учебный пример, который проверяет именно этот путь.

\n

Практический порядок важнее количества масок. Сначала не передавать объект запроса. Затем выбрать короткий контекст. Затем заменить известные чувствительные узлы. Наконец, проверить сериализованный результат. При таком порядке новая интеграция не получает неявное право приносить любой JSON. Она должна добавить поле к контракту и объяснить его цену, иначе требование «сохранить для отладки» станет бесконечным исключением.

\n

Кардинальность проверяется в именах до первого запроса

\n

Высокая кардинальность редко выглядит угрозой в одном примере. Один номер заказа, одна строка внешней ошибки и один полный URL кажутся безобидными. Проблема появляется, когда они становятся формой поля: через неделю журнал содержит множество вариантов, где одинаковое событие нельзя отличить от нового. Поэтому правило проверяют не количеством строк, а источником значения. Если оно рождается из идентификатора, текста пользователя, query string или свободного исключения, ему не место в event, route или имени сервиса.

\n

Нормализация не обязана скрывать причину. Вместо текста можно выбрать error.code из маленького списка, вместо полного URL — route template, вместо динамического события — пару «устойчивое имя события + локальный технический код». Тогда человек всё ещё видит, куда идти: adapter.response.rejected и TRAINING_SCHEMA_MISMATCH ведут к адаптеру и его контракту. Но поиск не создаёт отдельную категорию на каждый учебный заказ или случайную фразу.

\n

Маршрут ревью для поля и записи

\n
  1. Назвать диагностический вопрос, который должно закрыть новое поле; без вопроса поле не добавлять.
  2. Отнести поле к базовому, ограниченному, redact или запрещённому классу.
  3. Для строк с множеством значений выбрать нормализованный код, шаблон маршрута или словарное имя события.
  4. Перед сериализацией прогнать синтетический объект через redaction и проверить точный JSON-результат.
  5. По одному учебному request_id убедиться, что запись отвечает на вопрос без полного запроса, пользователя или секрета.
  6. На ревью спросить, не используется ли высококардинальное поле как группа и не скрывается ли необязательная информация во вложенном объекте.
\n

Такой разбор связывает безопасность и диагностику без громких обещаний. В журнале остаётся ровно столько контекста, чтобы объяснить одну известную ветвь, а не восстановить целое production-событие. Следующее улучшение может потребовать отдельного формата ошибок, доступа к хранилищу или модели трассировки. Но до него полезно закрепить базовую дисциплину: событие имеет ограниченный словарь, один request_id ищется отдельно, а чувствительное значение не должно быть принято в журнал даже «временно».

\n

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

\n"}