Files
progcode/editorial/agent-rewrites/270.json
T

8 lines
22 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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]) =&gt; {\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) =&gt;\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>{&quot;timestamp&quot;:&quot;2020-07-14T09:30:11.001Z&quot;,&quot;level&quot;:&quot;info&quot;,&quot;service&quot;:&quot;demo-gateway&quot;,&quot;environment&quot;:&quot;training&quot;,&quot;event&quot;:&quot;http.request.received&quot;,&quot;request_id&quot;:&quot;req-demo-20200714-01&quot;,&quot;message&quot;:&quot;Synthetic request accepted&quot;}\n{&quot;timestamp&quot;:&quot;2020-07-14T09:30:11.021Z&quot;,&quot;level&quot;:&quot;info&quot;,&quot;service&quot;:&quot;demo-catalog-api&quot;,&quot;environment&quot;:&quot;training&quot;,&quot;event&quot;:&quot;order.validation.completed&quot;,&quot;request_id&quot;:&quot;req-demo-20200714-01&quot;,&quot;message&quot;:&quot;Synthetic order passed validation&quot;}\n{&quot;timestamp&quot;:&quot;2020-07-14T09:30:11.042Z&quot;,&quot;level&quot;:&quot;info&quot;,&quot;service&quot;:&quot;demo-gateway&quot;,&quot;environment&quot;:&quot;training&quot;,&quot;event&quot;:&quot;http.request.completed&quot;,&quot;request_id&quot;:&quot;req-demo-20200714-01&quot;,&quot;message&quot;:&quot;Synthetic request completed&quot;}\n\n# Учебный поиск в JSONL, не production-команда:\njq -c 'select(.request_id == &quot;req-demo-20200714-01&quot;)' 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>"
}