2 lines
19 KiB
JSON
2 lines
19 KiB
JSON
{"index":268,"slug":"editorial-2020-07-field-structured-logs","title":"Структурированные логи: поля, которым можно доверять","excerpt":"Как связать событие с запросом, удалить чувствительные данные до сериализации и не превратить журнал в набор уникальных строк.","contentHtml":"<p>Авария начинается с простого поиска. Пользователь сообщает, что заказ не оформился. В журнале есть ошибка, но её нельзя связать с конкретным запросом: одно сообщение содержит длинный URL, другое — весь объект запроса, третье — текст исключения с номером заказа. Иногда рядом оказывается заголовок, похожий на токен. Диагностика останавливается. Инженер читает тысячи строк вручную, а затем ещё проверяет, не утёк ли секрет.</p>\n<p>Цена ошибки складывается из трёх частей. Команда дольше восстанавливает цепочку событий. Система хранения получает лишний объём и множество уникальных значений. Доступ к журналу открывает данные, которые не требовались для ответа на диагностический вопрос. Строка с красивым текстом не решает ни одну из этих проблем сама по себе.</p>\n<p><strong>Тезис:</strong> структурированный лог — это небольшой контракт события. В нём есть устойчивое имя события, владелец операции, идентификатор связи и ограниченный набор нормализованных полей. Контракт нужно сформировать до сериализации. Тогда поиск использует поля, redaction видит структуру объекта, а каждое добавленное значение можно объяснить.</p>\n<h2>Что именно делает лог полезным</h2>\n<p>Сначала назовём вопрос. Например: «какой запрос привёл к отказу адаптера?» Для него нужны <code>request_id</code>, имя события, сервис, маршрут-шаблон, код результата и нормализованный код причины. Полное тело запроса не нужно. Текст исключения тоже не нужен, если в нём нет устойчивого кода, который можно проверить.</p>\n<p><code>request_id</code> связывает записи одного входного действия. Он подходит для поиска одной истории. Он не доказывает причинность и не заменяет трассировку: два сервиса могут записать события с разной задержкой, а фоновой задаче может потребоваться новый <code>operation_id</code>. Это ограничение важно назвать до внедрения, иначе один идентификатор начнут использовать как универсальную модель системы.</p>\n<p>У остальных полей другой режим. <code>event</code> описывает тип события и должен иметь небольшой словарь. <code>route</code> содержит шаблон, а не конкретный путь с идентификатором заказа. <code>service</code> обозначает владельца границы, а не имя pod или локальный путь. <code>error_code</code> называет известную причину из ограниченного набора. Эти поля подходят для фильтра и группы.</p>\n<table><caption>Назначение полей структурированного события</caption><thead><tr><th scope=\"col\">Поле</th><th scope=\"col\">Роль</th><th scope=\"col\">Допустимое значение</th><th scope=\"col\">Чего не делать</th></tr></thead><tbody><tr><td><code>request_id</code></td><td>найти одну историю</td><td>стабильный идентификатор запроса</td><td>использовать как метрику-группу</td></tr><tr><td><code>event</code></td><td>назвать тип события</td><td><code>adapter.response.rejected</code></td><td>вставлять номер заказа или текст ошибки</td></tr><tr><td><code>route</code></td><td>сравнить обработчики</td><td><code>/orders/:orderId</code></td><td>писать полный URL и query string</td></tr><tr><td><code>error_code</code></td><td>разделить причины</td><td><code>SCHEMA_MISMATCH</code></td><td>сохранять произвольное сообщение исключения</td></tr><tr><td><code>authorization</code></td><td>не нужна для поиска события</td><td>не записывать или заменить на <code>[REDACTED]</code></td><td>передавать сырой объект headers</td></tr></tbody></table>\n<h2>Redaction выполняется до JSON.stringify</h2>\n<p>Поздняя маскировка ломается на границе строк. Если сначала выполнить <code>JSON.stringify(request)</code>, а потом искать секрет регулярным выражением, правило зависит от вложенности, регистра ключа и формата значения. Неожиданный объект легко попадёт в stdout целиком. Маска должна получить объект, пройти известные ключи и только затем передать безопасную копию сериализатору.</p>\n<pre><code>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 не является реальным секретом.</code></pre>\n<p>В результате учебного вызова значение <code>headers.authorization</code> должно стать <code>[REDACTED]</code>. Проверять нужно именно строку, которая уйдёт в поток вывода. Проверка внутреннего объекта недостаточна: код может безопасно изменить копию, а затем случайно залогировать исходную. Также нельзя считать список ключей полной защитой. Интеграция может назвать поле <code>credential</code>, вложить секрет в строку или передать его под другим именем.</p>\n<p>Надёжнее не передавать логгеру сырой запрос. Контроллер выбирает метод, шаблон маршрута и код ответа. Адаптер выбирает своё имя и нормализованный код ошибки. Redaction остаётся второй границей, а не разрешением писать любой JSON. Если поле не нужно для конкретного вопроса, его удаляют, а не маскируют «на всякий случай».</p>\n<h2>Кардинальность определяет качество поиска</h2>\n<p>Кардинальность — это число разных значений поля. Для <code>event</code> она должна быть низкой. Иначе вместо одного фильтра <code>adapter.response.rejected</code> появятся сотни имён: <code>order.7421.failed</code>, <code>order.7422.failed</code> и так далее. Журнал сохранит все строки, но перестанет давать устойчивую группу. Полный URL, свободный текст исключения и имя пользователя создают ту же проблему.</p>\n<p>Высокая кардинальность иногда нужна. У <code>request_id</code> она намеренно высокая, потому что поле находит одну историю. Ошибка возникает, когда его начинают использовать для агрегирования или строят по нему долговременный отчёт. Для группы нужны устойчивые поля. Для единичного расследования нужен идентификатор с ограниченным сроком и понятной областью действия.</p>\n<figure><img src=\"/assets/editorial/2020/structured-log-diagnosis-2020.svg\" alt=\"Дерево выбора поля для структурированного лога: диагностический вопрос, безопасное поле, нормализованный контекст, redaction или отказ от записи\" loading=\"lazy\" /><figcaption>Поле проходит проверку вопросом «зачем оно нужно?». Если значение не ведёт к проверяемому действию, оно не входит в событие. Иллюстрация показывает учебную схему, а не карту конкретной production-системы.</figcaption></figure>\n<h2>Один учебный сбой</h2>\n<p>Ниже приведён синтетический JSONL-пример. Он показывает форму данных, а не результат реального инцидента. Один <code>request_id</code> связывает вход, отказ адаптера и ответ gateway. Внешняя причина сведена к коду. Номер заказа и тело запроса отсутствуют.</p>\n<pre><code>{\"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.</code></pre>\n<p>По этой цепочке можно проверить только форму расследования: найти три записи, увидеть известный код и отделить владельца отказа от gateway. Нельзя делать вывод о частоте 502, времени восстановления или работе реального адаптера. Для таких выводов нужны данные конкретной среды и отдельные измерения. Учебный пример полезен тем, что фиксирует минимальный контракт без ложной статистики.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><caption>Диагностика типичных дефектов журнала</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Ошибка найдена, но запрос не находится</td><td>request_id теряется на HTTP-границе</td><td>сравнить записи клиента и сервиса по одному учебному идентификатору</td><td>передавать контекст явно через исходящий клиент</td></tr><tr><td>В событии виден токен или cookie</td><td>сырой объект сериализовали раньше redaction</td><td>проверить финальную stdout-строку на запрещённые значения</td><td>выбирать поля вручную и маскировать до сериализации</td></tr><tr><td>Каждая ошибка создаёт новый event</td><td>динамическое имя содержит id или свободный текст</td><td>посчитать варианты event на учебной выборке</td><td>оставить устойчивое имя и добавить ограниченный error_code</td></tr><tr><td>Группа по сервису постоянно меняется</td><td>в service попали pod, путь или версия процесса</td><td>сравнить значение с владельцем логической границы</td><td>отделить service от instance и deployment-метаданных</td></tr><tr><td>Лог красивый, но JSON не разбирается</td><td>строка собрана вручную или содержит неэкранированный ввод</td><td>прогнать каждую строку через JSON.parse</td><td>сериализовать объект штатным JSON-генератором и санитизировать ввод</td></tr></tbody></table>\n<h2>Порядок внедрения</h2>\n<ol><li>Сформулировать один диагностический вопрос и границу операции.</li><li>Назначить владельца <code>request_id</code> и описать поведение для отсутствующего или невалидного значения.</li><li>Составить маленький словарь событий, маршрутов и кодов ошибок. Не добавлять динамику в имена.</li><li>Выбрать безопасные поля на каждой границе. Не передавать полный request, headers, body или объект пользователя.</li><li>Применить redaction к синтетическому объекту до сериализации и проверить итоговый JSON.</li><li>Сделать три связанные учебные записи от двух модулей и найти их по одному request_id.</li><li>Проверить отрицательный путь: ошибка обработчика, отсутствующий идентификатор, невалидный заголовок и неожиданный внешний текст.</li><li>Зафиксировать критерий готовности в тесте или проверяемой команде, а затем проверить реальные форматы конкретного логгера.</li></ol>\n<h2>Ограничения</h2>\n<p>Структурированный лог не создаёт трассировку. Он не показывает дочерние spans, не исправляет рассинхрон часов и не объясняет фоновые задачи, если для них не определён отдельный идентификатор. Для распределённого критического пути понадобится трассировочный контракт.</p>\n<p>Redaction защищает только известные формы. Он не распознаёт любой секрет и не заменяет классификацию данных, права доступа, срок хранения и контроль конфигурации. Высокая кардинальность не всегда вредна: уникальный идентификатор полезен для одной истории, но опасен как поле длительной агрегации. Правило зависит от назначения поля.</p>\n<p>Критерий готовности проверяемый: каждая учебная строка проходит <code>JSON.parse</code>; три связанные записи находятся по одному <code>request_id</code>; <code>event</code>, <code>service</code> и <code>route</code> не содержат динамических идентификаторов; запрещённые ключи не выходят в исходном виде; отрицательный путь сохраняет нормализованный <code>error_code</code>. Если хотя бы одно условие не выполняется, контракт ещё не готов.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc8259.html\" target=\"_blank\" rel=\"noopener\">RFC 8259: JSON Data Interchange Format</a> — синтаксис JSON и требования к сериализации.</li><li><a href=\"https://github.com/pinojs/pino/blob/main/docs/api.md\" target=\"_blank\" rel=\"noopener\">Pino API: child loggers и redact</a> — официальная документация API; возможности нужно сверять с версией библиотеки.</li><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener\">OWASP Logging Cheat Sheet</a> — данные, которые следует удалять, маскировать или не записывать.</li></ul>"}
|