ADR 0026 — Фаза 5 (сессия 3): graceful shutdown, OpenTelemetry, метрики, логи и дашборды
- Статус: принят
- Дата: 2026-07-26
- Фаза: 5 (сессия 3 — пункты 4 и 5 объёма)
- Следует за: ADR 0025, чьё ограничение 1 («нет дренирования сессий при остановке») решается здесь.
Контекст
Сессии 1 и 2 поставили production-образы, CI и деплой в Kubernetes с HPA engine по глубине очереди. Graceful shutdown и наблюдаемость были явно отложены на потом.
Эта сессия поставляет и то, и другое. Критерий приёмки был двойным и исполнимым: rollout с активными сессиями без единой осиротевшей и, в локальном Grafana, проследить сессию от корневого trace до конкретного tool call, видя её стоимость на дашборде.
Большой работой было не написание инструментирования, а обнаружение того, что масштабирование engine было сломано с прошлой сессии. Появилось шесть дефектов, и ни один из них не ломал тесты: пять были молчаливыми по построению.
Решения
1. У сессии ОДИН владелец в кластере: регистрация :global
Engine.Sessions.SessionServer регистрировался в Registry, локальном для узла, а
Engine.Sessions.Rehydrator при загрузке воссоздаёт процесс для каждой строки
engine.session_states, а это глобальная таблица. С N репликами каждая сессия
начинала существовать N раз; websocket браузера приходит к ОДНОЙ (Service
балансирует), а остальные N−1 копий, не получая ping, превышали heartbeat
30 с и велели api завершить живую сессию в другом pod.
То есть масштабирование engine убивало сессии пользователей за 30 секунд, а
make hpa-test, одобренный в сессии 2, масштабирует до 3.
Имя перешло на :global. Это устраняет дубликаты между узлами и, в придачу, позволяет
heartbeat/1 достучаться до владельца, где бы он ни был, — раньше join канала падал,
когда websocket попадал на «не тот» pod. Registry остаётся в дереве,
потому что серверы агентов (po:, criativo:, arquiteto:, infra:) используют его
с префиксными ключами.
2. :global разрешает конфликт имён, УБИВАЯ процесс, — и это объясняло всё
Это был самый трудный дефект сессии, и стоит зафиксировать метод, потому что доказательства несколько раундов указывали не туда.
Симптом: drain прекрасно работал при ручном вызове (5 sessões, 5 com node_shutdown) и ничего не дренировал во время настоящего rollout —
preStop сообщал total: 0, а api регистрировала сессии как killed.
Причина: при rollout (maxSurge: 1) новый pod поднимается, пока старый ещё
размещает сессии. Он регидратировал до того, как :global синхронизировал
таблицы имён, whereis_name возвращал :undefined для имён, существовавших
на другом узле, и он создавал вторую копию каждой сессии. Когда синхронизация
завершалась, :global разрешал конфликт резолвером по умолчанию
random_exit_name, который убивает один из двух процессов через
exit(pid, :kill). Жребий убивал копию старого pod, который приходил к
preStop без ничего локального для дренирования.
Непустой Node.list() говорит подключён, а не «таблицы обменяны». Не хватает
:global.sync(), и именно он закрывает окно. Rehydrator стал ждать
кластер и синхронизироваться перед регидратацией — и только когда есть сессия для регидратации,
чтобы не штрафовать загрузку с пустой таблицей.
3. Проактивный drain в preStop, а не terminate/2
Когда супервизор опускается, каждый SessionServer получает exit(:shutdown) и умирает
мгновенно — он не перехватывает exits, у него нет terminate/2. Добавление
trap_exit решило бы наполовину: супервизор даёт 5 с на потомка, а drain
нуждается в сети (событие в api, handoff на другой узел).
Поэтому preStop вызывает Engine.Shutdown.drain(), пока весь BEAM ещё работает.
Для каждой локальной сессии: выдаёт session.draining, освобождает глобальное имя и предлагает
сессию пиру через :erpc. Не принятые переходят active → closing
(причина node_shutdown), а затем в closed_abnormally.
Принятая сессия остаётся active и никогда не становится closing — машина состояний
не позволяет вернуться из closing в active, поэтому объявление closing для
всего сделало бы принятие невозможным. Это ответ на «closing/node_shutdown ИЛИ
регидратированные» из формулировки: обе половины, решаемые для каждой сессии.
drain/0 возвращает сводку (%{total:, adopted:, terminated:}) вместо :ok,
потому что stdout хука — единственная запись, остающаяся после исчезновения pod, — именно
эта сводка позволила диагностировать решение 2.
Engine.Sessions.Adopter покрывает то, до чего не дотягивается preStop: kill -9,
OOMKill, вытесненный узел. Без него «ноль осиротевших» действовало бы только для вежливой
остановки.
4. Четыре блокировки на границе api↔engine
- Внутренний DTO отклонял
to: "closing"(@IsIn(['closed','closed_abnormally'])); единственным маршрутом, принимавшимclosing, был человеческий, за RBAC пользователя. termination_reasonсохранялся только в терминальном состоянии, поэтому причинаnode_shutdownотбрасывалась бы ровно там, гдеTerminationClassifierPsicólogo будет её читать.- Классификатор не знал
node_shutdown. Она вошла до catch-allclosed_abnormally, иначе drain выглядел бы как:crash, и Psicólogo выдвинул бы гипотезу о несуществующем дефекте. - DTO гипотез отклонил бы новую причину, и режим отказа плох: 400
возвращается модели как результат tool, и ToolLoop крутится, пока не превысит
max_iterations, тратя бюджет и ничего не регистрируя.
5. force_ssl отвечал 301 на каждом /internal/*
Исключение Plug.SSL совпадает по точному пути (conn.path_info in paths),
поэтому paths: ["/health", ...] никогда не покрыл бы двадцать с лишним маршрутов
/internal. Api не отправляет x-forwarded-proto: https, а у engine нет
HTTPS-listener — TLS завершается в ingress. Результат: api→engine был сломан
со сессии 2.
И ни один smoke этого не видел, потому что создание сессии не вызывает engine — её
активация вызывает. Оба smoke стали активировать, а комментарий, утверждавший, что доказывает
этот путь, перестал быть ложным. Решение об исключении стало функцией
(EngineWeb.ForceSslExclusions), потому что список точных путей
устаревает на первом новом endpoint.
6. Модель trace: сессия — корень, сохраняемый
Сессия длится минуты или часы, а span OTel доходит до бэкенда, только когда завершается: корень, открытый всё это время, был бы невидим в Tempo как раз пока сессия идёт, и исчез бы совсем, если бы сессия никогда не завершилась правильно, — а это случай, которым занимается пункт 4.
Поэтому корень короткий (session.create), а его traceparent сохраняется в
sessions.trace_parent. Вся последующая работа использует его как удалённого родителя,
разделяет trace_id, и вся сессия восстанавливается по одному id.
Распространение по трём путям, каждый в одной точке:
| путь | точка внедрения |
|---|---|
| api → engine | buildHeaders() — раньше это были четыре одинаковых встроенных блока |
| engine → api | post_returning/3, воронка каждого POST engine |
| outbox → Oban | DrizzleOutboxRepository.append() читает активный контекст; 18 мест вызова не изменились |
outbox_events получила колонку metadata jsonb, а не ключ в payload:
engine десериализует payload по типу события, и смешение транспорта с
доменом отравило бы всех производителей. Engine хранит собственную копию
traceparent в engine.session_states — копию, а не join, потому что чтение таблицы
api на каждом ходе агента ради неизменного значения меняет одну запись
на N чтений.
Контекст OTel живёт в словаре процесса, а engine запускает работу
через cast и Task. Span.capture/0 и Span.attach/1 переносят контекст вручную —
без этой пары trace разрывается ровно в самых интересных точках.
7. Счётчик для скорости, gauge для состояния
- «токены/мин» и «стоимость/час» — это скорости: монотонный счётчик с
rate(), выводящим окно. Дашборд просит в минуту, алерт — в час, и ни одному не нужна собственная метрика;rate()также обрабатывает перезапуск процесса. - «активные сессии» и «заблокированные задачи» — это состояние: нет события
«перестала быть активной», которое пережило бы перезапуск. Периодический запрос к базе, с
reset()перед перезаписью — без reset серия залипает, и панель показывает уже закончившуюся работу.
Две доменные детали, которые изменили бы запрос: «tasks blocked» — это
булева колонка tasks.blocked (заблокированная задача возвращается в todo), а у tasks
нет project_id — путь story_id → stories. Доля одобрений — счётчик
на решении, а не count(status='approved'): одобренное действие, которое
выполняется, становится executed.
/metrics api нуждается в @Public(): JwtAuthGuard глобален, и без этого
scrape получает 401, а симптомом становится target down при всём остальном зелёном.
8. JSON-логи: trace_id через mixin, и имя — это контракт
В api mixin pino вычисляется на каждой строке, читая активный контекст
в этот момент, — именно это заставляет строку, выданную внутри tool call, нести
trace этого tool call без того, чтобы какая-либо точка логирования передавала id.
Поле — trace_id с подчёркиванием во всех трёх сервисах, потому что именно его
извлекает stage.json Alloy и ищет derivedFields Loki. Переименование
компилируется, проходит набор тестов и уничтожает кликабельную корреляцию.
В engine — форматтер, написанный вручную (~30 строк), вместо библиотеки: варианты несут форматтеры для трёх облаков и собственную схему конфигурации, и ни один не внедряет контекст OTel без склейки так или иначе. Он никогда не бросает — исключение там произошло бы внутри логгера, теряя строку и рискуя войти в рекурсию при попытке залогировать сбой.
В web — без браузерного SDK: @opentelemetry/sdk-trace-web стоит ~90 kB и требует
CORS в коллекторе и записи в CSP. Web генерирует traceparent на
запрос, который api принимает как родителя (это стандартный пропагатор), и логирует с
тем же trace_id. Это ~20 строк, и замена на SDK позже — это замена одной
функции. ApiError стал нести traceId, поэтому «произошла ошибка» стало действенным
сообщением.
Глобальные хуки, которых не было: QueryCache/MutationCache onError и
window.error/unhandledrejection — без них исключение рендеринга было белым
экраном без записи.
9. Collector посередине, и что через него НЕ проходит
OTEL_EXPORTER_OTLP_ENDPOINT указывает на OpenTelemetry Collector, а не на
Tempo: три приложения знают только один endpoint, а смена бэкенда, сэмплирование или
фильтрация атрибутов становятся изменением конвейера.
Метрики и логи не проходят через него: метрика — это scrape (pull-модель, переживает перезапуск коллектора), а лог читается Alloy из stdout pod. Отправка всех трёх вещей одним путём сделала бы коллектор единой точкой отказа всей наблюдаемости.
NetworkPolicy allow-otlp-egress обязательна: default-deny egress
namespace brabo (ADR 0025) блокирует OTLP в monitoring, а режим
отказа худший из возможных — приложения поднимаются, spans создаются, отправка падает, и
всё остальное зелёное при пустом Tempo.
10. Алерты Grafana, а не Prometheus — зафиксированное отклонение
Формулировка требует «алертов Prometheus». Они реализованы как единые правила Grafana, подготовленные как код, по явному выбору пользователя. Принятая плата: они перестают вычисляться, если Grafana упадёт, чего не случилось бы с правилами, загруженными в Prometheus. Нет ни Alertmanager, ни receiver — маршрутизация — эксплуатационное решение.
Последствия
- Engine поддерживает более одной реплики на деле, и rollout сохраняет сессии пользователей вместо того, чтобы их убивать.
- Сессия прослеживается от начала до конца:
session.createв api,agent.turn,tool.call,llm.turnиgate.scannerв engine, всё в одномtrace_id. - Логи трёх сервисов — JSON с одним и тем же
trace_id, кликабельным из Tempo в Loki и обратно. pnpm-workspace.yamlстал объявлятьprotobufjs: false: он входит как транзитивная зависимость экспортёра OTLP, а используемый нами экспортёр — HTTP/JSON; не запускать postinstall транзитивной зависимости — та же дисциплина закрепления и контрольных сумм, что и в остальной фазе.
Известные ограничения (зафиксированы, не решены)
- Серверы агентов остаются локальными для узла.
po:,criativo:,arquiteto:иinfra:остаются в локальномRegistry, с той же формой дефекта решения 1: команда агента, попавшая на «не тот» pod, не находит процесс. Это не проявилось в критерии приёмки этой сессии, но это следующая деталь для миграции на:global. - У
Adopterесть окно разделения сети. Он синхронизирует:globalперед принятием, но при 1 репликеNode.list()законно пуст, и нельзя отличить «один, потому что единственная реплика» от «один, потому что сеть разделена». При реальном разделении две стороны могли бы принять одну и ту же сессию. tool.callреального агента не проверен от начала до конца. Проверка использовала механизм span напрямую (Span.with_sessionчерезrpc), потому что запуск настоящего агента требует LLM — Ollama или ключа API, — а в окружении не было ни одного. Путь кода тот же; не наблюдался реальный ход агента, производящий дерево.- Chart
grafana/grafanaпомечен как устаревший в upstream- репозитории, а единственная замена —grafana-operator(CRD, другая модель подготовки). - Пункты 6 и 7 Фазы 5 остаются открытыми: резервное копирование/восстановление с запланированным
pg_dumpи протестированным runbook; rate limit, заголовки безопасности, строгий CORS и аудит зависимостей в CI. - Публикация образов в registry по-прежнему ожидает со сессии 2 и является предварительным условием overlays staging/prod.