0052. Структурированные журналы, корреляция запросов и метрики процесса
Статус: accepted. Дата: 2026-10-01.
Контекст
После ADR 0048 у API и worker был единственный DiagnosticLogger, но аудит эксплуатации показал:
- уровни только
info|warning|error, без настройки; компонентыapi|storage|worker; строки длиннее 2048 символов молча исчезали, счётчик потерь был зашит в код (diagnostics.dropped.N, всегдаstorage); - строки не содержали службу, версию, PID и хост — по одной строке нельзя было понять, какой процесс её написал;
- запуск API, миграции, GC и scrub печатали произвольный текст (stderr/stdout вперемешку, JSON без времени и уровня); потеря владения, drain и его тайм-аут не журналировались; не было обработчиков
unhandledRejection/uncaughtException; - worker писал только итог задачи без
durationMs,attempts,uploadId,generation; исчерпание попыток и отказ продления lease проходили без строки; фоновые задачи писалиcleanup.unavailableбез причины; - входящий
X-Request-Idигнорировался,traceparentне читался;requestIdбыл в ответе об ошибке, но не попадал в задачи и аудит, хотя API_EVOLUTION обещает request ID в аудите; - метрик не было вовсе, хотя PROJECT_PLAN требует p95/p99, ошибки и повторы;
- вывод планировщика обновлений Windows терялся, а deploy CLI печатал текст без времени и скрывал только URL PostgreSQL.
Решение
- Журнал.
DiagnosticLoggerостаётся собственным и без зависимостей (pino и подобные не нужны для JSON-строк и усложнили бы контроль backpressure). Уровниdebug|info|warning|error, минимальный уровеньARKVORY_LOG_LEVEL(по умолчаниюinfo) проверяется при загрузке конфигурации: неизвестное значение — ошибка запуска. Фильтр применяется до сериализации. Компоненты — закрытое объединениеapi|http|storage|worker|maintenance|migrate|process|diagnostics. Каждая строка начинается сtimestamp,level,service(api|worker|migrate|gc|scrub),version(изreleases/<version>/release.json, иначеdev),pid,hostname(ограниченный набор символов, ≤ 64), затемcomponent,code. Набор полей закрыт интерфейсомDiagnosticFields; свободный текст — толькоreason, уже прошедший редактор. - Backpressure и размер. Логгер не буферизует сверх high-water mark потока: пока
write()вернулfalseили поток в ошибке, записи считаются и отбрасываются; наdrainпишется одна записьdiagnostics.droppedс числовым полемdropped. Строковые поля обрезаются (256 символов,reason— 240) с полемtruncated; если строка всё равно длиннее 4096 символов (экранирование), она заменяется ограниченной записьюdiagnostics.oversizedсrecordCodeиrequestId. Учётные записиdiagnostics.*не фильтруются уровнем. Счётчикиwritten/dropped/truncated/oversizedдоступны в метриках. - Корреляция.
X-Request-Idпринимается, только если непосредственный peer входит вARKVORY_TRUSTED_PROXIESи значение — одно, 8–128 символов[A-Za-z0-9._:-], начинается с буквы или цифры; иначе генерируется UUID. Значение по-прежнему возвращается вX-Request-Id. W3Ctraceparentпроверяется строго (нижний регистр hex, версия ≠ff, ненулевые trace/parent id, для версии00без хвоста) и даёт полеtraceIdв access log и HTTP-диагностике; заголовок не возвращается и никогда не используется для прав.traceparentпринимается от любого peer: это контекст для поиска, а не идентификатор сервера, поэтому подделка влияет только на поиск по trace ID, но не наrequestId, ответы, задачи и аудит. - Передача requestId (миграция 24). Expand-only: nullable
request_id varchar(128)вarkvory_jobs,arkvory_security_audit,arkvory_audit;SCHEMA_VERSION = 24. Значение передаётся явно, без глобального состояния и AsyncLocalStorage:RequestContext.authenticateсохраняет копиюPrincipalс необязательным полемrequestId, и оно проходит через уже существующие параметры сценариев (Principal→MutationAccess→ порт хранилища). Политики авторизации поле не читают. Анонимныеlogin/registerполучают его отдельным параметром,SecurityActor— необязательнымrequestId. Повторная постановка задачи заменяетrequest_idна запрос, который её поставил. Worker продолжает выполнение сrequestIdзадачи. Публичные списки аудита и ответ задачи не меняются: поле не добавлено в wire-контракт, чтобы не менять SDK; корреляция выполняется по журналу или SQL. - Worker.
completion.started(debug), итогcompletion.completed|failed|lease_lostсjobId,uploadId,repository,generation,attempts,requestId,durationMs,errorCodeи идентификаторами причины. Исчерпание попыток вынесено вJobStore.exhaust()(UPDATE … RETURNING): каждая такая задача даёт ровно одну строкуcompletion.attempts_exhausted(error) у того worker, чей UPDATE её завершил;take()больше не делает это неявно. Отказ продления lease —completion.heartbeat_failed(warning) через наблюдательPostgresJobLease. Фоновые задачи API пишутerrorName/errno/sqlstateчерезfailureCause. GC и scrub пишутgc.*/scrub.*записи (scrub.verification_failedсartifactId), короткая подсказка остаётся в stderr. - Жизненный цикл.
api.listening(адрес и порт),api.ownership_lost,drain.started(окно, активные запросы, сигнал),drain.settled/drain.timeoutиapi.stoppedсdurationMs,drain.expeditedдля второго сигнала,api.stop_timeout/api.stop_failed.migrate.started/completed/failedсfromSchema/toSchema. Ошибка запуска —startup.failed(API) иworker.unavailable(worker) в stdout плюс короткая подсказка в stderr. API, worker, GC и scrub ставят обработчикиunhandledRejection/uncaughtException: одна записьprocess.unhandled(origin, редактированныйreason,errorName/errno/sqlstate), ожидание сброса журнала до 1 с, выход с кодом 1 (crash-only; перезапускает супервизор). Логгер точек входа не закрывается до выхода, чтобы поздний сбой тоже был записан. - Метрики.
GET /health/metrics(getMetrics, аддитивная операция): Prometheus text 0.0.4, авторизация как у/health/ready— любой действующий credential (401 без него), вне бюджетаARKVORY_MAX_REQUESTS, доступен во время drain. Реестр без зависимостей; метки ограничены шаблоном маршрута (илиunmatched), нормализованным методом, классом статуса, направлением передачи; у каждой метрики не более 2048 серий, лишние сворачиваются в сериюotherи считаются вarkvory_metrics_series_overflow_total. Состав: счётчик ответов и гистограмма длительности (фиксированные границы 5 мс–30 мин), байты запросов/ответов (дельты сокета), активные передачи, глубина очереди и отказы допуска, запросы в работе, записи журнала, версия, время старта, RSS; из БД — число queued/running completion jobs и время ожидания самой давней готовой к выполнению queued-задачи без учёта backoff (кеш 5 с, single-flight, ошибка — счётчик и пропуск значения). Значения процессные и сбрасываются при перезапуске. У worker нет HTTP-слушателя: счётчики исходов задач не публикуются, их дают строкиcompletion.*. - Deploy. Одна функция
redactSecretsдля всех строк deploy CLI (URL PostgreSQL целиком, учётные данные в URL, заголовки Authorization, Bearer, токены GitHub и Arkvory,key=value-секреты, длинные непрозрачные строки), формат<ISO-8601> <LEVEL> <текст>. Планировщик обновлений Windows без консоли перезапускаетupdates-pollс выводом вlogs/updater.log(дескриптор файла передаётся дочернему процессу напрямую, без pipe), ротация по размеру 20 MiB × 5 файлов как у WinSW. Аргументы задачи не меняются, поэтому откат на старый релиз не ломает регистрацию.
Альтернативы
- pino/OpenTelemetry SDK: зрелые форматы и экспорт, но новые зависимости, собственная буферизация и сложнее доказать «не блокирует передачу». Формат JSON-строк совместим со сборщиками (Vector, Fluent Bit, Loki), OTLP можно добавить отдельным ADR.
- AsyncLocalStorage для requestId: меньше правок сигнатур, но скрытый контекст (service locator) теряется на границах очередей и таймеров и не виден в портах. Поле
Principalуже передаётся явно в каждый сценарий. - Отдельный параметр
requestIdво всех сценариях: явнее, но меняет десятки сигнатур и тестов без выигрыша в безопасности. - Публичный
/metrics: стандартно для Prometheus, но раскрывает маршруты, нагрузку и очереди; оставлено под тем же правилом, что подробная готовность. - Метрики worker через таблицу БД: потребовали бы новой записи на каждый исход и отдельного ADR о росте таблицы.
Последствия
- Новый код требует миграцию 24 (порядок прежний: migrate, затем запуск). Она только добавляет nullable столбцы без перезаписи таблиц и долгих блокировок; contract-шаг не нужен. Строки, записанные до миграции или процессом без запроса, хранят NULL.
ARKVORY_LOG_LEVEL=warning|errorскрывает иhttp.access(info). Код записейdiagnostics.dropped.Nиdiagnostics.queue_dropped.Nзаменён наdiagnostics.dropped/diagnostics.queue_droppedс полемdropped; компоненты access log и HTTP-ошибок —http, жизненного цикла —process; записьstartup.failedтеперь в stdout.- Вывод GC/scrub — JSON-строки; скрипты, читавшие единственную JSON-строку scrub, должны искать запись
scrub.completed. - Без
ARKVORY_TRUSTED_PROXIESвходящийX-Request-Idвсегда заменяется; балансировщик должен брать ID из ответа. Доверенный proxy обязан перезаписыватьX-Request-Id(nginx:proxy_set_header X-Request-Id $request_id), иначе через него пройдёт значение клиента; пример обновлён. - 403 для
/health/metricsне возникает: достаточно любого действующего credential, как для/health/ready. Отдельное правоsystem.observeостаётся проектным (API_MAP). - Счётчики метрик принадлежат процессу: при нескольких API их суммирует Prometheus; после рестарта используется
arkvory_process_start_time_seconds.
Проверка
tests/observability.test.mjs: фильтр уровней до сериализации, поля процесса и их ограничения, версия из манифеста, обрезка иdiagnostics.oversized, учёт потерь, правилаX-Request-Id(доверенный/недоверенный peer, CIDR, IPv4-mapped, формат),traceparent, наблюдатель heartbeat, журнал drain.tests/metrics.test.mjs: формат text 0.0.4, экранирование, кумулятивные гистограммы, сворачивание серий, метки маршрутов без сырых URL, gauges допуска.tests/startup-diagnostics.test.mjs: дочерние API и worker пишутprocess.unhandledбез секретов и выходят с кодом 1.tests/deploy-output.test.mjs: формат строк, редактирование, ротация, захват вывода.tests/integration/observability.test.mjs:request_idв задаче, строках worker (включаяattempts_exhausted), каталожном и security-аудите; недоверенный peer; неизменность списков аудита;traceIdв access log; 401 и содержимое/health/metrics.tests/integration/migration-upgrade.test.mjs,review-regressions.test.mjs: схема 24 одинакова для новой и обновлённой БД, readiness согласована сSCHEMA_VERSION.