8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"index": 270,
|
||
"slug": "editorial-2020-07-practice-structured-logs",
|
||
"title": "Структурированные логи: как связать один запрос и быстро найти ошибку",
|
||
"excerpt": "Контракт JSON-события, единый request_id и проверка отрицательного пути помогают найти запрос без поиска по случайному тексту. Разбираем форму записи, границы корреляции и redaction.",
|
||
"contentHtml": "<p>В журнале появляются три строки: «ошибка оплаты», «запрос завершён» и стек исключения. Они стоят рядом, но неизвестно, относятся ли к одному запросу. Инженер ищет по минуте, маршруту и фрагменту текста и может связать записи разных действий. Цена ошибки — неверная правка, повторный инцидент и потерянное время во время сбоя.</p>\n<p>Причина обычно не в отсутствии логов. Приложение пишет слишком мало устойчивых признаков. Сообщение меняется от версии к версии, порядок строк зависит от параллельной работы, а полный объект запроса содержит лишние и чувствительные данные. Одна строка не даёт надёжного ключа поиска.</p>\n<p>Рабочий минимум — контракт одного JSON-события и один <code>request_id</code> на входное HTTP-действие. Обязательные поля имеют постоянные имена, а локальный модуль добавляет только свой контекст. Такой журнал отвечает на узкий вопрос: какие известные события принадлежат этому запросу? Он не заменяет распределённую трассировку и не доказывает причинность.</p>\n<h2>Сначала фиксируем контракт события</h2>\n<p>Структурированный лог — это объект с полями, а не строка, которую потом приходится разбирать регулярным выражением. Человек читает короткое поле <code>message</code>. Поиск и агрегация используют <code>event</code>, <code>service</code>, уровень и идентификатор запроса. JSON сам по себе ничего не гарантирует: поля становятся полезными только тогда, когда команда закрепила их смысл и форму.</p>\n<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><code>timestamp</code></td><td>логгер</td><td>UTC ISO-8601</td><td>Когда произошло событие?</td></tr><tr><td><code>level</code></td><td>операция</td><td><code>debug</code>, <code>info</code>, <code>warn</code>, <code>error</code></td><td>Насколько срочно его смотреть?</td></tr><tr><td><code>service</code></td><td>конфигурация</td><td>устойчивое имя</td><td>Где оно произошло?</td></tr><tr><td><code>environment</code></td><td>конфигурация</td><td>например, <code>training</code></td><td>Не смешаны ли контуры?</td></tr><tr><td><code>event</code></td><td>владелец операции</td><td><code>noun.verb</code></td><td>Что произошло?</td></tr><tr><td><code>request_id</code></td><td>HTTP-граница</td><td>одно проверенное значение</td><td>Какие записи относятся к запросу?</td></tr><tr><td><code>message</code></td><td>владелец операции</td><td>короткий текст</td><td>Что увидит читатель?</td></tr></tbody></table>\n<p>Общие поля живут в корне объекта. Контекст операции — во вложенном блоке <code>http</code>, <code>job</code> или <code>adapter</code>. Не передавайте в логгер целиком <code>req</code>, ответ базы или объект пользователя. В такой форме могут оказаться заголовки, тело запроса и секреты. Выберите несколько полей, которые закрывают конкретный вопрос, и договоритесь о словаре событий до первого поиска.</p>\n<figure><img src='/assets/editorial/2020/structured-log-event-2020.svg' alt='Схема учебного JSON-события: обязательные поля отделены от локального блока HTTP' loading='lazy' /><figcaption>Общий контракт остаётся коротким, а локальный HTTP-контекст находится в отдельном блоке. Учебная иллюстрация показывает форму записи, а не результат production-наблюдения.</figcaption></figure>\n<h2>Проверяем запись до вывода</h2>\n<p>Функция формирования события должна принимать только известный контекст. В примере она проверяет обязательные строки, уровень, формат учебного идентификатора и дату до сериализации. Затем рекурсивно заменяет значения полей из списка redaction. Это не универсальная библиотека и не схема всей системы: фиксированные значения нужны, чтобы повторить результат на чистой среде.</p>\n<pre><code>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));</code></pre>\n<p><code>route</code> здесь — шаблон, а не полный URL с номером заказа. Так сто заказов не создают сто новых значений маршрута. Имя <code>event</code> тоже выбирают из небольшого словаря: <code>http.request.received</code>, <code>order.validation.failed</code>, <code>http.request.completed</code>. Не включайте в имя номер ошибки, текст исключения или идентификатор пользователя. Поле перестанет быть пригодным для группировки.</p>\n<p>Проверка формы и проверка цепочки — разные проверки. Первая смотрит один объект: есть ли обязательные строки, допустим ли уровень, проходит ли идентификатор и замаскированы ли чувствительные значения. Вторая смотрит несколько событий: совпадает ли <code>request_id</code>, есть ли ожидаемые начало и завершение, не пропал ли контекст на границе сервиса. Если первая проверка падает, исправляют builder. Если вторая — место передачи контекста.</p>\n<h2>Повторяем один синтетический запрос</h2>\n<p>Ниже приведены учебные записи. Они не взяты из production, не описывают реального пользователя и не доказывают работу конкретной системы. Их задача — показать минимальный запрос, который можно выбрать по одному ключу. Сначала сохраните строки в <code>training.jsonl</code>, затем выполните поиск.</p>\n<pre><code>{"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</code></pre>\n<p>Ожидаемый результат содержит три записи и один идентификатор. Если запись API отсутствует, проверяют передачу контекста и наличие события в этом модуле. Если API пишет другой идентификатор, ищут вторую генерацию на исходящем вызове. Если завершение повторяется, проверяют повторный вызов обработчика. Эти выводы ограничены учебным набором: по нему нельзя утверждать, что все реальные сервисы системы пишут логи.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<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>Ошибка есть, но запрос не найти</td><td>Нет общего <code>request_id</code></td><td>Сравнить обязательные поля у соседних событий</td><td>Назначить владельца идентификатора на HTTP-входе</td></tr><tr><td>Один модуль виден, второй нет</td><td>Контекст не передан через HTTP-клиент</td><td>Проверить заголовок и событие на обеих сторонах</td><td>Передавать контекст явно в сигнатуре адаптера</td></tr><tr><td>JSON валиден, поиск ломается</td><td><code>requestId</code> вместо <code>request_id</code> или свободное имя события</td><td>Проверить ключи и словарь событий</td><td>Исправить builder и добавить проверку схемы</td></tr><tr><td>Лог содержит токен или cookie</td><td>Сериализовали сырой объект запроса</td><td>Проверить запись до stdout и fixture redaction</td><td>Исключить поле или заменить значение до JSON</td></tr><tr><td>События рядом по времени, но связь не доказана</td><td>Время ошибочно приняли за корреляцию</td><td>Найти общий идентификатор в каждой записи</td><td>Не делать вывод без ключа; для причинности нужна трассировка</td></tr></tbody></table>\n<h2>Передаём request_id через границу</h2>\n<p>Идентификатор создаёт или принимает первая HTTP-граница. Она проверяет длину и допустимые символы. Формат <code>req-demo-YYYYMMDD-NN</code> — только внутреннее правило учебного примера, не стандарт HTTP и не обязательный формат для рабочего проекта. Клиентский заголовок нельзя без проверки копировать в журнал: он может быть слишком длинным, содержать управляющие символы или использоваться для загрязнения поиска.</p>\n<p>После входа создают дочерний логгер с общими полями и передают его в следующий модуль. Не храните текущий идентификатор в глобальной переменной: два параллельных запроса перезапишут значение. Не генерируйте новый ключ в адаптере. Иначе каждая запись будет выглядеть аккуратно, но цепочка распадётся.</p>\n<p>Обработчик ошибки должен использовать тот же контекст. Частая ошибка — создать новый логгер внутри <code>catch</code> и записать стек без <code>request_id</code>. Для диагностики это самое дорогое место: именно важное событие выпадает из поиска. Сериализуйте нормализованный код ошибки и короткое сообщение. Полный объект исключения может содержать запрос, заголовки и данные внешней системы.</p>\n<p>Фоновая задача требует отдельного решения. Она может продолжать исходное действие, а может быть новым действием. Механически копировать <code>request_id</code> нельзя: это создаёт ложную непрерывную трассу. Если связь нужна, храните её как явную ссылку, а для попытки используйте отдельный <code>operation_id</code>. В этой статье фоновая очередь не моделируется.</p>\n<h2>Не путать корреляцию с причинностью</h2>\n<p><code>request_id</code> полезен для поиска одной истории. Он высококардинален и плохо подходит для графика. <code>event</code> и <code>service</code> имеют ограниченный словарь и подходят для подсчёта повторяющихся случаев. Не превращайте уникальный идентификатор в имя метрики или тег каждого агрегата.</p>\n<p>Корреляция не равна причинности. Одинаковый ключ показывает принадлежность одному входному действию, но не показывает точный порядок параллельных операций. Часы машин могут расходиться, а отложенная запись — появиться позже. Если нужен критический путь, дочерние операции или межсервисные задержки, нужна отдельная модель трассировки. Сортировка строк по времени эту модель не создаёт.</p>\n<p>Redaction выполняют до сериализации. Маска в интерфейсе просмотра недостаточна: секрет уже мог попасть в файл, транспорт или резервную копию. Минимальный список для отдельной проверки — <code>authorization</code>, <code>cookie</code>, <code>password</code>, <code>token</code> и <code>secret</code>. Реальный список зависит от приложения и должен жить рядом с кодом формирования записи.</p>\n<h2>Порядок проверки</h2>\n<ol><li>Выберите один HTTP-маршрут и назовите владельца <code>request_id</code>.</li><li>Зафиксируйте обязательные поля и словарь из нескольких имён событий.</li><li>Добавьте валидатор идентификатора, уровня и даты; задайте явное поведение для неверного входа.</li><li>Создайте дочерний логгер на входе и передайте контекст через исходящий клиент.</li><li>Соберите три синтетические записи от двух модулей с одним ключом.</li><li>Проверьте форму каждого объекта и поиск всей цепочки в JSONL.</li><li>Добавьте redaction до сериализации и отрицательную проверку для секретного поля.</li><li>Только после успешной проверки перенесите контракт на соседний маршрут.</li></ol>\n<h2>Граница результата</h2>\n<p>Минимальный контракт не показывает работу сервиса, полноту журнала, доставку записи, задержку коллектора или реальный пользовательский эффект. Учебный fixture не читает production-логи, не отправляет сеть и не устанавливает причину инцидента. Синтетические значения нельзя выдавать за измеренные результаты.</p>\n<p>Практика готова, когда разрешённый учебный запрос возвращает все ожидаемые события по одному <code>request_id</code>; каждый объект проходит проверку обязательных полей; отрицательный тест обнаруживает потерю или замену ключа; redaction не выпускает заданные чувствительные значения; команда может назвать границу, на которой нужно искать пропажу контекста. Если хотя бы один пункт не выполнен, контракт ещё не даёт проверяемой диагностики.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://www.rfc-editor.org/rfc/rfc5424.html' target='_blank' rel='noopener noreferrer'>RFC 5424: The Syslog Protocol</a> — описывает структурированные данные в формате syslog; это не утверждение, что учебный JSON является сообщением syslog на проводе.</li><li><a href='https://www.rfc-editor.org/rfc/rfc8259.html' target='_blank' rel='noopener noreferrer'>RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format</a> — фиксирует синтаксис JSON, который в примере получают через <code>JSON.stringify</code>.</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>"
|
||
}
|