Перейти к основному содержимому

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 отбрасывалась бы ровно там, где TerminationClassifier Psicólogo будет её читать.
  • Классификатор не знал node_shutdown. Она вошла до catch-all closed_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 → enginebuildHeaders() — раньше это были четыре одинаковых встроенных блока
engine → apipost_returning/3, воронка каждого POST engine
outbox → ObanDrizzleOutboxRepository.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 транзитивной зависимости — та же дисциплина закрепления и контрольных сумм, что и в остальной фазе.

Известные ограничения (зафиксированы, не решены)​

  1. Серверы агентов остаются локальными для узла. po:, criativo:, arquiteto: и infra: остаются в локальном Registry, с той же формой дефекта решения 1: команда агента, попавшая на «не тот» pod, не находит процесс. Это не проявилось в критерии приёмки этой сессии, но это следующая деталь для миграции на :global.
  2. У Adopter есть окно разделения сети. Он синхронизирует :global перед принятием, но при 1 реплике Node.list() законно пуст, и нельзя отличить «один, потому что единственная реплика» от «один, потому что сеть разделена». При реальном разделении две стороны могли бы принять одну и ту же сессию.
  3. tool.call реального агента не проверен от начала до конца. Проверка использовала механизм span напрямую (Span.with_session через rpc), потому что запуск настоящего агента требует LLM — Ollama или ключа API, — а в окружении не было ни одного. Путь кода тот же; не наблюдался реальный ход агента, производящий дерево.
  4. Chart grafana/grafana помечен как устаревший в upstream- репозитории, а единственная замена — grafana-operator (CRD, другая модель подготовки).
  5. Пункты 6 и 7 Фазы 5 остаются открытыми: резервное копирование/восстановление с запланированным pg_dump и протестированным runbook; rate limit, заголовки безопасности, строгий CORS и аудит зависимостей в CI.
  6. Публикация образов в registry по-прежнему ожидает со сессии 2 и является предварительным условием overlays staging/prod.