{ "index": 269, "slug": "editorial-2020-07-mechanism-structured-logs", "title": "Один request_id через границы: как связать журнал без ложной трассировки", "excerpt": "Если gateway, API и worker пишут рядом, но не связывают события, диагностика превращается в догадку. Разбираем владельца request_id, передачу контекста, отрицательный путь и границы корреляции.", "contentHtml": "

Симптом появляется во время разбора сбоя: gateway вернул 502, API записал ошибку адаптера, а worker сообщил о повторе операции. Время у строк почти одинаковое, но соседний запрос мог пройти в тот же момент. Нельзя доказать, что записи относятся к одной операции. Цена ошибки — изменить таймаут не того сервиса, повторить уже принятую запись или закрыть инцидент без найденной границы потери контекста.

\n

Тезис статьи простой: структурированный лог полезен только тогда, когда событие можно связать с проверяемым действием. Для одного HTTP-запроса достаточно начать с внутреннего request_id. Входная граница принимает или создаёт один идентификатор, проверяет его, передаёт через исходящий вызов и добавляет в дочерний логгер. Это корреляция, а не распределённая трассировка. Она не создаёт spans, не объясняет критический путь и не доказывает причинность.

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

Владелец идентификатора находится на входе

\n

Идентификатор должен иметь одного владельца. Для HTTP им становится middleware или обработчик, который первым принимает запрос. Он читает X-Request-Id, но не копирует заголовок вслепую. Сначала проверяет длину и допустимые символы. Если значение отсутствует или не подходит формату, граница создаёт новое. Внутренний сервис не должен незаметно заменить этот ключ своим.

\n

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

\n
Диагностика потери связи между событиями
СимптомПричинаПроверкаДействие
Gateway и API пишут разные request_idВнутренний модуль сгенерировал новый ключСверить входной заголовок и код создания контекстаОставить генерацию только на входной границе
Событие API не находится по ключу gatewayИсходящий клиент не передал заголовокПроверить фактические headers учебного вызоваПередать контекст явно в сигнатуру клиента
Ошибка есть, но request_id отсутствуетВетка catch создала новый логгерСделать контролируемую ошибку и найти её событиеИспользовать тот же дочерний логгер
Две попытки выглядят как два запросаДля локальной операции нет operation_idСверить request_id, номер попытки и состояниеДобавить operation_id, не заменяя request_id
Ключ совпадает, но причина неяснаКорреляцию приняли за причинностьПроверить параллельные ветви, часы и порядок событийВвести отдельный контракт трассировки
\n

Передача контекста — это зависимость

\n

Глобальная переменная для «текущего запроса» ломается при конкуренции. Второй запрос перезаписывает значение, пока первый ещё выполняется. События получают чужой ключ. Ещё одна хрупкая схема — передавать строку неявно и надеяться, что каждый вызывающий код добавит её в лог. Место передачи скрывается, а ошибка проявляется только в отдельной ветке.

\n

Передавайте небольшой контекст явно. Он может содержать request_id и дочерний логгер. Функция, которая вызывает другой сервис, получает его параметром. Такой контракт виден в сигнатуре, проверяется тестом и читается в ревью. Библиотека логирования помогает прикрепить поля к дочернему логгеру, но не знает, какой клиент вы вызовете и где создаётся новая операция.

\n
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}
\n

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

\n

У дочерней операции может быть собственный operation_id. Например, один запрос запускает две попытки резервирования. request_id отвечает на вопрос «к какому входному действию относится событие?». operation_id отвечает на вопрос «какая локальная попытка его создала?». Не подменяйте один ключ другим. Если вопрос не требует различать попытки, дополнительное поле только увеличит схему.

\n

Один синтетический путь в JSONL

\n

Следующий набор полностью учебный. Идентификатор, время, маршрут и сообщения придуманы для проверки механизма. Это не выгрузка production-журнала и не результат измерения реального сервиса.

\n
{\"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
\n

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

\n

Ошибочный путь должен быть виден

\n

Самый важный лог часто теряет контекст в обработчике исключения. Код ловит ошибку, создаёт новый логгер и записывает только текст. Для пользователя ответ может быть правильным, но для диагностики связь исчезает. Проверка должна специально вызвать контролируемую ошибку и убедиться, что в событии остались request_id, service, стабильный event и нормализованный код ошибки.

\n

Не сериализуйте исключение целиком без фильтра. В нём могут оказаться заголовки, токены, cookie или тело запроса. Сначала выберите поля, нужные для решения: класс ошибки, внутренний код, безопасное описание и место сбоя. Защита должна работать до сериализации. Вложенный логгер облегчает передачу контекста, а redaction ограничивает чувствительные поля; ни один из механизмов не исправляет неверно выбранную схему.

\n

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

\n

Корреляция не равна трассировке

\n

Одинаковый request_id показывает принадлежность событий одному входному действию. Он не доказывает порядок на уровне сети и процессора. Две ветви могут идти параллельно. Часы разных машин могут расходиться. Асинхронный обработчик может записать событие после ответа. Сортировка JSONL по timestamp не превращает журнал в граф причин.

\n

Если нужно найти критический путь, измерить ожидание между сервисами или связать дочерние операции в нескольких процессах, нужен отдельный trace-контракт. В нём описывают родительские и дочерние spans, перенос контекста, sampling и проверку экспортируемых данных. Нельзя назвать простой внутренний заголовок распределённой трассировкой только потому, что его значение повторяется в нескольких логах.

\n

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

\n
  1. Назовите входную HTTP-границу и объявите её владельцем request_id.
  2. Опишите допустимый формат, длину и действие при отсутствии или невалидном заголовке.
  3. Создайте дочерний логгер на границе и добавьте request_id в обязательный набор событий.
  4. Передайте контекст явно через исходящий HTTP-клиент; проверьте заголовок на обеих сторонах.
  5. Сделайте синтетический путь с событиями входа, локальной операции и завершения.
  6. Проверьте отрицательные случаи: потерянный заголовок, повторная генерация, ошибка в catch и две локальные попытки.
  7. Отдельно проверьте redaction и убедитесь, что ключ не содержит пользователя, заказ или секрет.
  8. Решите, нужен ли operation_id или уже требуется полноценный trace-контракт.
\n

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

\n

Эта схема не даёт метрик latency, SLO, распределённых spans, гарантии доставки логов или доказательства причинности. Она не решает передачу контекста через каждую очередь и не определяет retention. Она также не отменяет валидацию входных заголовков, контроль доступа и правила удаления чувствительных данных. Переход на библиотеку сам по себе не закрывает ни одну из этих границ.

\n

Механизм готов для заявленного узкого вопроса, если выполнены четыре условия. Входная граница единолично владеет ключом. Два сервиса получают один и тот же request_id через проверенный HTTP-вызов. Синтетический успешный и ошибочный пути дают события, которые находятся одним фильтром. Тест отдельно фиксирует потерю или подмену ключа и не принимает совпадение времени за доказательство. Если хотя бы одно условие не выполнено, корреляция не подтверждена.

\n

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

\n" }