{ "index": 270, "slug": "editorial-2020-07-practice-structured-logs", "title": "Структурированные логи: как связать один запрос и быстро найти ошибку", "excerpt": "Контракт JSON-события, единый request_id и проверка отрицательного пути помогают найти запрос без поиска по случайному тексту. Разбираем форму записи, границы корреляции и redaction.", "contentHtml": "
В журнале появляются три строки: «ошибка оплаты», «запрос завершён» и стек исключения. Они стоят рядом, но неизвестно, относятся ли к одному запросу. Инженер ищет по минуте, маршруту и фрагменту текста и может связать записи разных действий. Цена ошибки — неверная правка, повторный инцидент и потерянное время во время сбоя.
\nПричина обычно не в отсутствии логов. Приложение пишет слишком мало устойчивых признаков. Сообщение меняется от версии к версии, порядок строк зависит от параллельной работы, а полный объект запроса содержит лишние и чувствительные данные. Одна строка не даёт надёжного ключа поиска.
\nРабочий минимум — контракт одного JSON-события и один request_id на входное HTTP-действие. Обязательные поля имеют постоянные имена, а локальный модуль добавляет только свой контекст. Такой журнал отвечает на узкий вопрос: какие известные события принадлежат этому запросу? Он не заменяет распределённую трассировку и не доказывает причинность.
Структурированный лог — это объект с полями, а не строка, которую потом приходится разбирать регулярным выражением. Человек читает короткое поле message. Поиск и агрегация используют event, service, уровень и идентификатор запроса. JSON сам по себе ничего не гарантирует: поля становятся полезными только тогда, когда команда закрепила их смысл и форму.
| Поле | Кто задаёт | Форма | Вопрос |
|---|---|---|---|
timestamp | логгер | UTC ISO-8601 | Когда произошло событие? |
level | операция | debug, info, warn, error | Насколько срочно его смотреть? |
service | конфигурация | устойчивое имя | Где оно произошло? |
environment | конфигурация | например, training | Не смешаны ли контуры? |
event | владелец операции | noun.verb | Что произошло? |
request_id | HTTP-граница | одно проверенное значение | Какие записи относятся к запросу? |
message | владелец операции | короткий текст | Что увидит читатель? |
Общие поля живут в корне объекта. Контекст операции — во вложенном блоке http, job или adapter. Не передавайте в логгер целиком req, ответ базы или объект пользователя. В такой форме могут оказаться заголовки, тело запроса и секреты. Выберите несколько полей, которые закрывают конкретный вопрос, и договоритесь о словаре событий до первого поиска.
Функция формирования события должна принимать только известный контекст. В примере она проверяет обязательные строки, уровень, формат учебного идентификатора и дату до сериализации. Затем рекурсивно заменяет значения полей из списка redaction. Это не универсальная библиотека и не схема всей системы: фиксированные значения нужны, чтобы повторить результат на чистой среде.
\nconst 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));\nroute здесь — шаблон, а не полный URL с номером заказа. Так сто заказов не создают сто новых значений маршрута. Имя event тоже выбирают из небольшого словаря: http.request.received, order.validation.failed, http.request.completed. Не включайте в имя номер ошибки, текст исключения или идентификатор пользователя. Поле перестанет быть пригодным для группировки.
Проверка формы и проверка цепочки — разные проверки. Первая смотрит один объект: есть ли обязательные строки, допустим ли уровень, проходит ли идентификатор и замаскированы ли чувствительные значения. Вторая смотрит несколько событий: совпадает ли request_id, есть ли ожидаемые начало и завершение, не пропал ли контекст на границе сервиса. Если первая проверка падает, исправляют builder. Если вторая — место передачи контекста.
Ниже приведены учебные записи. Они не взяты из production, не описывают реального пользователя и не доказывают работу конкретной системы. Их задача — показать минимальный запрос, который можно выбрать по одному ключу. Сначала сохраните строки в training.jsonl, затем выполните поиск.
{"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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Ошибка есть, но запрос не найти | Нет общего request_id | Сравнить обязательные поля у соседних событий | Назначить владельца идентификатора на HTTP-входе |
| Один модуль виден, второй нет | Контекст не передан через HTTP-клиент | Проверить заголовок и событие на обеих сторонах | Передавать контекст явно в сигнатуре адаптера |
| JSON валиден, поиск ломается | requestId вместо request_id или свободное имя события | Проверить ключи и словарь событий | Исправить builder и добавить проверку схемы |
| Лог содержит токен или cookie | Сериализовали сырой объект запроса | Проверить запись до stdout и fixture redaction | Исключить поле или заменить значение до JSON |
| События рядом по времени, но связь не доказана | Время ошибочно приняли за корреляцию | Найти общий идентификатор в каждой записи | Не делать вывод без ключа; для причинности нужна трассировка |
Идентификатор создаёт или принимает первая HTTP-граница. Она проверяет длину и допустимые символы. Формат req-demo-YYYYMMDD-NN — только внутреннее правило учебного примера, не стандарт HTTP и не обязательный формат для рабочего проекта. Клиентский заголовок нельзя без проверки копировать в журнал: он может быть слишком длинным, содержать управляющие символы или использоваться для загрязнения поиска.
После входа создают дочерний логгер с общими полями и передают его в следующий модуль. Не храните текущий идентификатор в глобальной переменной: два параллельных запроса перезапишут значение. Не генерируйте новый ключ в адаптере. Иначе каждая запись будет выглядеть аккуратно, но цепочка распадётся.
\nОбработчик ошибки должен использовать тот же контекст. Частая ошибка — создать новый логгер внутри catch и записать стек без request_id. Для диагностики это самое дорогое место: именно важное событие выпадает из поиска. Сериализуйте нормализованный код ошибки и короткое сообщение. Полный объект исключения может содержать запрос, заголовки и данные внешней системы.
Фоновая задача требует отдельного решения. Она может продолжать исходное действие, а может быть новым действием. Механически копировать request_id нельзя: это создаёт ложную непрерывную трассу. Если связь нужна, храните её как явную ссылку, а для попытки используйте отдельный operation_id. В этой статье фоновая очередь не моделируется.
request_id полезен для поиска одной истории. Он высококардинален и плохо подходит для графика. event и service имеют ограниченный словарь и подходят для подсчёта повторяющихся случаев. Не превращайте уникальный идентификатор в имя метрики или тег каждого агрегата.
Корреляция не равна причинности. Одинаковый ключ показывает принадлежность одному входному действию, но не показывает точный порядок параллельных операций. Часы машин могут расходиться, а отложенная запись — появиться позже. Если нужен критический путь, дочерние операции или межсервисные задержки, нужна отдельная модель трассировки. Сортировка строк по времени эту модель не создаёт.
\nRedaction выполняют до сериализации. Маска в интерфейсе просмотра недостаточна: секрет уже мог попасть в файл, транспорт или резервную копию. Минимальный список для отдельной проверки — authorization, cookie, password, token и secret. Реальный список зависит от приложения и должен жить рядом с кодом формирования записи.
request_id.Минимальный контракт не показывает работу сервиса, полноту журнала, доставку записи, задержку коллектора или реальный пользовательский эффект. Учебный fixture не читает production-логи, не отправляет сеть и не устанавливает причину инцидента. Синтетические значения нельзя выдавать за измеренные результаты.
\nПрактика готова, когда разрешённый учебный запрос возвращает все ожидаемые события по одному request_id; каждый объект проходит проверку обязательных полей; отрицательный тест обнаруживает потерю или замену ключа; redaction не выпускает заданные чувствительные значения; команда может назвать границу, на которой нужно искать пропажу контекста. Если хотя бы один пункт не выполнен, контракт ещё не даёт проверяемой диагностики.
JSON.stringify.