{ "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, ответ базы или объект пользователя. Форма такого объекта меняется без предупреждения. В ней могут оказаться заголовки, тело запроса и секреты. Выберите несколько полей, которые закрывают конкретный вопрос.
Функция формирования события должна принимать только известный контекст. Она проверяет обязательные ключи до сериализации. Это не универсальная библиотека и не схема всей системы. В учебном примере время и идентификатор заданы явно, чтобы результат можно было повторить. В рабочем приложении логгер обычно добавляет время сам.
\nconst required = [\n 'timestamp', 'level', 'service', 'environment',\n 'event', 'request_id', 'message'\n];\n\nfunction buildEvent(base, local) {\n const event = { ...base, ...local };\n const missing = required.filter((name) => event[name] === undefined);\n if (missing.length) {\n throw new Error('missing log fields: ' + missing.join(', '));\n }\n return 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', status_code: 202 } }\n);\n\nprocess.stdout.write(JSON.stringify(entry) + '\\n');\nroute здесь — шаблон, а не полный URL с номером заказа. Так сто заказов не создают сто новых значений маршрута. Имя event тоже выбирают из небольшого словаря: http.request.received, order.validation.failed, http.request.completed. Не включайте в имя номер ошибки, текст исключения или идентификатор пользователя. Поле перестанет быть пригодным для группировки.
Проверка формы и проверка цепочки — разные проверки. Первая смотрит один объект: есть ли ключи, допустим ли уровень, замаскированы ли чувствительные значения. Вторая смотрит несколько событий: совпадает ли request_id, есть ли ожидаемые начало и завершение, не пропал ли контекст на границе сервиса. Если первая проверка падает, исправляют builder. Если вторая — место передачи контекста.
Ниже приведены учебные записи. Они не взяты из production, не описывают реального пользователя и не доказывают работу конкретной системы. Их задача — показать минимальный запрос, который можно выбрать по одному ключу.
\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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Ошибка есть, но запрос не найти | Нет общего request_id | Сравнить обязательные поля у соседних событий | Назначить владельца идентификатора на HTTP-входе |
| Один модуль виден, второй нет | Контекст не передан через HTTP-клиент | Проверить заголовок и событие на обеих сторонах | Передавать контекст явно в сигнатуре адаптера |
| JSON валиден, поиск ломается | requestId вместо request_id или свободное имя события | Проверить ключи и словарь событий | Исправить builder и добавить проверку схемы |
| Лог содержит токен или cookie | Сериализовали сырой объект запроса | Проверить запись до stdout и fixture redaction | Исключить поле или заменить значение до JSON |
| События рядом по времени, но связь не доказана | Время ошибочно приняли за корреляцию | Найти общий идентификатор в каждой записи | Не делать вывод без ключа; для причинности нужна трассировка |
Идентификатор создаёт или принимает первая HTTP-граница. Она проверяет длину и допустимые символы. Внутренний формат учебного примера — req-demo-YYYYMMDD-NN. В рабочем коде формат может быть другим, но владельцем остаётся один слой. Клиентский заголовок нельзя без проверки копировать в журнал: он может быть слишком длинным, содержать управляющие символы или использоваться для загрязнения поиска.
После входа создают дочерний логгер с общими полями и передают его в следующий модуль. Не храните текущий идентификатор в глобальной переменной. Два параллельных запроса перезапишут значение. Не генерируйте новый ключ в адаптере. Иначе каждая запись будет выглядеть аккуратно, но цепочка распадётся.
\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 не выпускает заданные чувствительные значения; команда может назвать границу, на которой нужно искать пропажу контекста. Если хотя бы один пункт не выполнен, контракт ещё не даёт проверяемой диагностики.