{ "index": 270, "slug": "editorial-2020-07-practice-structured-logs", "title": "Структурированные логи: как связать один запрос и быстро найти ошибку", "excerpt": "Контракт JSON-события, единый request_id и проверка отрицательного пути помогают найти запрос без поиска по случайному тексту. Разбираем форму записи, границы корреляции и redaction.", "contentHtml": "

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

\n

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

\n

Рабочий минимум — контракт одного JSON-события и один request_id на входное HTTP-действие. Обязательные поля имеют постоянные имена, а локальный модуль добавляет только свой контекст. Такой журнал отвечает на узкий вопрос: какие известные события принадлежат этому запросу? Он не заменяет распределённую трассировку и не доказывает причинность.

\n

Сначала фиксируем контракт события

\n

Структурированный лог — это объект с полями, а не строка, которую потом приходится разбирать регулярным выражением. Человек читает короткое поле message. Поиск и агрегация используют event, service, уровень и идентификатор запроса. JSON сам по себе ничего не гарантирует: поля становятся полезными только тогда, когда команда закрепила их смысл и форму.

\n
Минимальный контракт учебного события
ПолеКто задаётФормаВопрос
timestampлоггерUTC ISO-8601Когда произошло событие?
levelоперацияdebug, info, warn, errorНасколько срочно его смотреть?
serviceконфигурацияустойчивое имяГде оно произошло?
environmentконфигурациянапример, trainingНе смешаны ли контуры?
eventвладелец операцииnoun.verbЧто произошло?
request_idHTTP-границаодно проверенное значениеКакие записи относятся к запросу?
messageвладелец операциикороткий текстЧто увидит читатель?
\n

Общие поля живут в корне объекта. Контекст операции — во вложенном блоке http, job или adapter. Не передавайте в логгер целиком req, ответ базы или объект пользователя. В такой форме могут оказаться заголовки, тело запроса и секреты. Выберите несколько полей, которые закрывают конкретный вопрос, и договоритесь о словаре событий до первого поиска.

\n
Схема учебного JSON-события: обязательные поля отделены от локального блока HTTP
Общий контракт остаётся коротким, а локальный HTTP-контекст находится в отдельном блоке. Учебная иллюстрация показывает форму записи, а не результат production-наблюдения.
\n

Проверяем запись до вывода

\n

Функция формирования события должна принимать только известный контекст. В примере она проверяет обязательные строки, уровень, формат учебного идентификатора и дату до сериализации. Затем рекурсивно заменяет значения полей из списка redaction. Это не универсальная библиотека и не схема всей системы: фиксированные значения нужны, чтобы повторить результат на чистой среде.

\n
const required = [\n  'timestamp', 'level', 'service', 'environment',\n  'event', 'request_id', 'message'\n];\nconst levels = new Set(['debug', 'info', 'warn', 'error']);\nconst requestId = /^req-demo-\\d{8}-\\d{2}$/;\nconst secretNames = new Set([\n  'authorization', 'cookie', 'password', 'token', 'secret'\n]);\n\nfunction redact(value) {\n  if (Array.isArray(value)) return value.map(redact);\n  if (!value || typeof value !== 'object') return value;\n  return Object.fromEntries(Object.entries(value).map(([key, item]) => {\n    const normalized = key.toLowerCase();\n    return secretNames.has(normalized)\n      ? [key, '[REDACTED]']\n      : [key, redact(item)];\n  }));\n}\n\nfunction buildEvent(base, local) {\n  const event = { ...base, ...local };\n  const missing = required.filter((name) =>\n    typeof event[name] !== 'string' || event[name].trim() === ''\n  );\n  if (missing.length) {\n    throw new Error('missing log fields: ' + missing.join(', '));\n  }\n  if (!levels.has(event.level)) throw new Error('invalid level');\n  if (!requestId.test(event.request_id)) throw new Error('invalid request_id');\n  if (Number.isNaN(Date.parse(event.timestamp))) throw new Error('invalid timestamp');\n  return redact(event);\n}\n\nconst entry = buildEvent(\n  {\n    timestamp: '2020-07-14T09:30:11.042Z',\n    level: 'info',\n    service: 'demo-catalog-api',\n    environment: 'training',\n    event: 'http.request.completed',\n    request_id: 'req-demo-20200714-01',\n    message: 'Synthetic request completed'\n  },\n  { http: { method: 'POST', route: '/training/orders/:orderId' } }\n);\n\nprocess.stdout.write(JSON.stringify(entry));
\n

route здесь — шаблон, а не полный URL с номером заказа. Так сто заказов не создают сто новых значений маршрута. Имя event тоже выбирают из небольшого словаря: http.request.received, order.validation.failed, http.request.completed. Не включайте в имя номер ошибки, текст исключения или идентификатор пользователя. Поле перестанет быть пригодным для группировки.

\n

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

\n

Повторяем один синтетический запрос

\n

Ниже приведены учебные записи. Они не взяты из production, не описывают реального пользователя и не доказывают работу конкретной системы. Их задача — показать минимальный запрос, который можно выбрать по одному ключу. Сначала сохраните строки в training.jsonl, затем выполните поиск.

\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"}\n{"timestamp":"2020-07-14T09:30:11.021Z","level":"info","service":"demo-catalog-api","environment":"training","event":"order.validation.completed","request_id":"req-demo-20200714-01","message":"Synthetic order passed validation"}\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"}\n\n# Учебный поиск в JSONL, не production-команда:\njq -c 'select(.request_id == "req-demo-20200714-01")' training.jsonl
\n

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

\n

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

\n
Короткий маршрут диагностики
СимптомПричинаПроверкаДействие
Ошибка есть, но запрос не найтиНет общего request_idСравнить обязательные поля у соседних событийНазначить владельца идентификатора на HTTP-входе
Один модуль виден, второй нетКонтекст не передан через HTTP-клиентПроверить заголовок и событие на обеих сторонахПередавать контекст явно в сигнатуре адаптера
JSON валиден, поиск ломаетсяrequestId вместо request_id или свободное имя событияПроверить ключи и словарь событийИсправить builder и добавить проверку схемы
Лог содержит токен или cookieСериализовали сырой объект запросаПроверить запись до stdout и fixture redactionИсключить поле или заменить значение до JSON
События рядом по времени, но связь не доказанаВремя ошибочно приняли за корреляциюНайти общий идентификатор в каждой записиНе делать вывод без ключа; для причинности нужна трассировка
\n

Передаём request_id через границу

\n

Идентификатор создаёт или принимает первая HTTP-граница. Она проверяет длину и допустимые символы. Формат req-demo-YYYYMMDD-NN — только внутреннее правило учебного примера, не стандарт HTTP и не обязательный формат для рабочего проекта. Клиентский заголовок нельзя без проверки копировать в журнал: он может быть слишком длинным, содержать управляющие символы или использоваться для загрязнения поиска.

\n

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

\n

Обработчик ошибки должен использовать тот же контекст. Частая ошибка — создать новый логгер внутри catch и записать стек без request_id. Для диагностики это самое дорогое место: именно важное событие выпадает из поиска. Сериализуйте нормализованный код ошибки и короткое сообщение. Полный объект исключения может содержать запрос, заголовки и данные внешней системы.

\n

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

\n

Не путать корреляцию с причинностью

\n

request_id полезен для поиска одной истории. Он высококардинален и плохо подходит для графика. event и service имеют ограниченный словарь и подходят для подсчёта повторяющихся случаев. Не превращайте уникальный идентификатор в имя метрики или тег каждого агрегата.

\n

Корреляция не равна причинности. Одинаковый ключ показывает принадлежность одному входному действию, но не показывает точный порядок параллельных операций. Часы машин могут расходиться, а отложенная запись — появиться позже. Если нужен критический путь, дочерние операции или межсервисные задержки, нужна отдельная модель трассировки. Сортировка строк по времени эту модель не создаёт.

\n

Redaction выполняют до сериализации. Маска в интерфейсе просмотра недостаточна: секрет уже мог попасть в файл, транспорт или резервную копию. Минимальный список для отдельной проверки — authorization, cookie, password, token и secret. Реальный список зависит от приложения и должен жить рядом с кодом формирования записи.

\n

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

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

Граница результата

\n

Минимальный контракт не показывает работу сервиса, полноту журнала, доставку записи, задержку коллектора или реальный пользовательский эффект. Учебный fixture не читает production-логи, не отправляет сеть и не устанавливает причину инцидента. Синтетические значения нельзя выдавать за измеренные результаты.

\n

Практика готова, когда разрешённый учебный запрос возвращает все ожидаемые события по одному request_id; каждый объект проходит проверку обязательных полей; отрицательный тест обнаруживает потерю или замену ключа; redaction не выпускает заданные чувствительные значения; команда может назвать границу, на которой нужно искать пропажу контекста. Если хотя бы один пункт не выполнен, контракт ещё не даёт проверяемой диагностики.

\n

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

\n" }