8 lines
19 KiB
JSON
8 lines
19 KiB
JSON
{
|
||
"index": 269,
|
||
"slug": "editorial-2020-07-mechanism-structured-logs",
|
||
"title": "Один request_id через границы: как связать журнал без ложной трассировки",
|
||
"excerpt": "Если gateway, API и worker пишут рядом, но не связывают события, диагностика превращается в догадку. Разбираем владельца request_id, передачу контекста, отрицательный путь и границы корреляции.",
|
||
"contentHtml": "<p>Симптом появляется во время разбора сбоя: gateway вернул 502, API записал ошибку адаптера, а worker сообщил о повторе операции. Время у строк почти одинаковое, но соседний запрос мог пройти в тот же момент. Нельзя доказать, что записи относятся к одной операции. Цена ошибки — изменить таймаут не того сервиса, повторить уже принятую запись или закрыть инцидент без найденной границы потери контекста.</p>\n<p>Тезис статьи простой: структурированный лог полезен только тогда, когда событие можно связать с проверяемым действием. Для одного HTTP-запроса достаточно начать с внутреннего <code>request_id</code>. Входная граница принимает или создаёт один идентификатор, проверяет его, передаёт через исходящий вызов и добавляет в дочерний логгер. Это корреляция, а не распределённая трассировка. Она не создаёт spans, не объясняет критический путь и не доказывает причинность.</p>\n<figure><img src=\"/assets/editorial/2020/structured-log-correlation-2020.svg\" alt=\"Схема корреляции одного учебного HTTP-запроса: gateway передаёт request_id сервису catalog-api, а оба сервиса пишут связанные события\" loading=\"lazy\" /><figcaption>Один ключ проходит через входную границу и исходящий HTTP-вызов. Схема показывает корреляцию событий, но не изображает spans и не измеряет критический путь.</figcaption></figure>\n<h2>Владелец идентификатора находится на входе</h2>\n<p>Идентификатор должен иметь одного владельца. Для HTTP им становится middleware или обработчик, который первым принимает запрос. Он читает <code>X-Request-Id</code>, но не копирует заголовок вслепую. Сначала проверяет длину и допустимые символы. Если значение отсутствует или не подходит формату, граница создаёт новое. Внутренний сервис не должен незаметно заменить этот ключ своим.</p>\n<p>Входной заголовок — служебный контекст, а не имя пользователя, номер заказа или секрет. Не помещайте в него персональные данные. Не разрешайте управляющие символы и произвольную длинную строку. В учебном примере формат читаемый: <code>req-demo-YYYYMMDD-NN</code>. В рабочем сервисе формат может быть UUID или другим принятым значением. Важны единый владелец, единая проверка и одно значение на путь запроса.</p>\n<div class=\"table-scroll\"><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>Gateway и API пишут разные request_id</td><td>Внутренний модуль сгенерировал новый ключ</td><td>Сверить входной заголовок и код создания контекста</td><td>Оставить генерацию только на входной границе</td></tr><tr><td>Событие API не находится по ключу gateway</td><td>Исходящий клиент не передал заголовок</td><td>Проверить фактические headers учебного вызова</td><td>Передать контекст явно в сигнатуру клиента</td></tr><tr><td>Ошибка есть, но request_id отсутствует</td><td>Ветка catch создала новый логгер</td><td>Сделать контролируемую ошибку и найти её событие</td><td>Использовать тот же дочерний логгер</td></tr><tr><td>Две попытки выглядят как два запроса</td><td>Для локальной операции нет operation_id</td><td>Сверить request_id, номер попытки и состояние</td><td>Добавить operation_id, не заменяя request_id</td></tr><tr><td>Ключ совпадает, но причина неясна</td><td>Корреляцию приняли за причинность</td><td>Проверить параллельные ветви, часы и порядок событий</td><td>Ввести отдельный контракт трассировки</td></tr></tbody></table></div>\n<h2>Передача контекста — это зависимость</h2>\n<p>Глобальная переменная для «текущего запроса» ломается при конкуренции. Второй запрос перезаписывает значение, пока первый ещё выполняется. События получают чужой ключ. Ещё одна хрупкая схема — передавать строку неявно и надеяться, что каждый вызывающий код добавит её в лог. Место передачи скрывается, а ошибка проявляется только в отдельной ветке.</p>\n<p>Передавайте небольшой контекст явно. Он может содержать request_id и дочерний логгер. Функция, которая вызывает другой сервис, получает его параметром. Такой контракт виден в сигнатуре, проверяется тестом и читается в ревью. Библиотека логирования помогает прикрепить поля к дочернему логгеру, но не знает, какой клиент вы вызовете и где создаётся новая операция.</p>\n<pre><code>function isRequestId(value) {\n return typeof value === 'string'\n && /^req-demo-[0-9]{8}-[0-9]{2}$/.test(value);\n}\n\nfunction makeRequestContext(baseLogger, incoming) {\n const requestId = isRequestId(incoming)\n ? incoming\n : 'req-demo-20200714-01';\n\n return {\n request_id: requestId,\n log: baseLogger.child({\n request_id: requestId,\n service: 'demo-gateway',\n environment: 'training'\n })\n };\n}\n\nasync function reserve(context, client) {\n context.log.info(\n { event: 'catalog.reserve.started' },\n 'Synthetic reservation started'\n );\n\n return client.post('/training/reservations', {\n headers: { 'x-request-id': context.request_id }\n });\n}</code></pre>\n<p>Код учебный. Фиксированное значение делает пример воспроизводимым. Оно не заменяет генератор случайных или криптографически стойких идентификаторов и не моделирует сервер, конкуренцию или сохранение контекста в очереди. В реальном коде не отражайте входное значение клиенту без правил валидации и не записывайте в лог полный объект запроса.</p>\n<p>У дочерней операции может быть собственный <code>operation_id</code>. Например, один запрос запускает две попытки резервирования. <code>request_id</code> отвечает на вопрос «к какому входному действию относится событие?». <code>operation_id</code> отвечает на вопрос «какая локальная попытка его создала?». Не подменяйте один ключ другим. Если вопрос не требует различать попытки, дополнительное поле только увеличит схему.</p>\n<h2>Один синтетический путь в JSONL</h2>\n<p>Следующий набор полностью учебный. Идентификатор, время, маршрут и сообщения придуманы для проверки механизма. Это не выгрузка production-журнала и не результат измерения реального сервиса.</p>\n<pre><code>{\"timestamp\":\"2020-07-14T09:30:11.001Z\",\"service\":\"demo-gateway\",\"level\":\"info\",\"event\":\"http.request.received\",\"request_id\":\"req-demo-20200714-01\",\"message\":\"Synthetic request accepted\"}\n{\"timestamp\":\"2020-07-14T09:30:11.018Z\",\"service\":\"demo-catalog-api\",\"level\":\"info\",\"event\":\"catalog.reserve.started\",\"request_id\":\"req-demo-20200714-01\",\"message\":\"Synthetic reservation started\"}\n{\"timestamp\":\"2020-07-14T09:30:11.042Z\",\"service\":\"demo-gateway\",\"level\":\"info\",\"event\":\"http.request.completed\",\"request_id\":\"req-demo-20200714-01\",\"message\":\"Synthetic request completed\"}\n\njq -s 'map(select(.request_id == \"req-demo-20200714-01\")) | sort_by(.timestamp)' training.jsonl</code></pre>\n<p>Ожидаемый учебный результат — три записи: вход, локальная операция и завершение. Если строка API имеет другой ключ, ищите повторную генерацию на исходящем вызове. Если строки API нет, проверяйте передачу заголовка и наличие события. Если завершение повторилось, разбирайте повторный вход или локальные попытки. Поиск по времени здесь только помогает читать вывод. Он не доказывает принадлежность.</p>\n<h2>Ошибочный путь должен быть виден</h2>\n<p>Самый важный лог часто теряет контекст в обработчике исключения. Код ловит ошибку, создаёт новый логгер и записывает только текст. Для пользователя ответ может быть правильным, но для диагностики связь исчезает. Проверка должна специально вызвать контролируемую ошибку и убедиться, что в событии остались <code>request_id</code>, <code>service</code>, стабильный <code>event</code> и нормализованный код ошибки.</p>\n<p>Не сериализуйте исключение целиком без фильтра. В нём могут оказаться заголовки, токены, cookie или тело запроса. Сначала выберите поля, нужные для решения: класс ошибки, внутренний код, безопасное описание и место сбоя. Защита должна работать до сериализации. Вложенный логгер облегчает передачу контекста, а redaction ограничивает чувствительные поля; ни один из механизмов не исправляет неверно выбранную схему.</p>\n<p>Отложенная задача требует отдельного решения. Если очередь действительно начинает новую операцию, не выдавайте её событие за непрерывное продолжение HTTP-запроса. Можно сохранить ссылку на исходный request_id как связь с инициатором, а попытке назначить собственный operation_id. Если обработчик живёт дольше HTTP-контекста, глобальная переменная особенно опасна. В этой статье очередь не реализуется: граница зафиксирована как ограничение примера.</p>\n<h2>Корреляция не равна трассировке</h2>\n<p>Одинаковый request_id показывает принадлежность событий одному входному действию. Он не доказывает порядок на уровне сети и процессора. Две ветви могут идти параллельно. Часы разных машин могут расходиться. Асинхронный обработчик может записать событие после ответа. Сортировка JSONL по timestamp не превращает журнал в граф причин.</p>\n<p>Если нужно найти критический путь, измерить ожидание между сервисами или связать дочерние операции в нескольких процессах, нужен отдельный trace-контракт. В нём описывают родительские и дочерние spans, перенос контекста, sampling и проверку экспортируемых данных. Нельзя назвать простой внутренний заголовок распределённой трассировкой только потому, что его значение повторяется в нескольких логах.</p>\n<h2>Порядок внедрения и проверки</h2>\n<ol><li>Назовите входную HTTP-границу и объявите её владельцем request_id.</li><li>Опишите допустимый формат, длину и действие при отсутствии или невалидном заголовке.</li><li>Создайте дочерний логгер на границе и добавьте request_id в обязательный набор событий.</li><li>Передайте контекст явно через исходящий HTTP-клиент; проверьте заголовок на обеих сторонах.</li><li>Сделайте синтетический путь с событиями входа, локальной операции и завершения.</li><li>Проверьте отрицательные случаи: потерянный заголовок, повторная генерация, ошибка в catch и две локальные попытки.</li><li>Отдельно проверьте redaction и убедитесь, что ключ не содержит пользователя, заказ или секрет.</li><li>Решите, нужен ли operation_id или уже требуется полноценный trace-контракт.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Эта схема не даёт метрик latency, SLO, распределённых spans, гарантии доставки логов или доказательства причинности. Она не решает передачу контекста через каждую очередь и не определяет retention. Она также не отменяет валидацию входных заголовков, контроль доступа и правила удаления чувствительных данных. Переход на библиотеку сам по себе не закрывает ни одну из этих границ.</p>\n<p>Механизм готов для заявленного узкого вопроса, если выполнены четыре условия. Входная граница единолично владеет ключом. Два сервиса получают один и тот же request_id через проверенный HTTP-вызов. Синтетический успешный и ошибочный пути дают события, которые находятся одним фильтром. Тест отдельно фиксирует потерю или подмену ключа и не принимает совпадение времени за доказательство. Если хотя бы одно условие не выполнено, корреляция не подтверждена.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://opentelemetry.io/docs/specs/otel/context/api-propagators/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry: Propagators API</a> — описывает extract и inject для переноса контекста через входящие и исходящие сообщения.</li><li><a href=\"https://github.com/pinojs/pino/blob/main/docs/api.md\" target=\"_blank\" rel=\"noopener noreferrer\">Pino: API documentation</a> — документирует child logger и redact; конкретные возможности нужно сверять с версией в проекте.</li><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP Logging Cheat Sheet</a> — задаёт практические требования к содержимому, согласованности и проверке журналирования.</li></ul>"
|
||
}
|