ADR 0070 — Наблюдаемость в локальном Compose: метрики и журналы, без трассировки
Контекст
ADR 0026 собрал полную наблюдаемость — Prometheus, Loki, Tempo, Alloy, OpenTelemetry Collector и Grafana — только для Kubernetes. Критерий приёмки той сессии гласил "в локальном Grafana", и "локальный" там означал кластер k3d, а не Docker Compose.
Практическое следствие: у того, кто разрабатывает через pnpm dev, работает всё
инструментирование — api отдаёт 102 серии на /metrics, engine отдаёт
oban_queue_depth и brabo_engine_sessions_hosted, три сервиса проставляют
trace_id в журнал, — и ничто из этого не наблюдаемо. Чтобы увидеть панель,
нужно было поднять кластер, который по замыслу не сосуществует с pnpm dev (оба
публикуют на одних и тех же портах, ADR 0025).
Решение
1. Opt-in overlay, а не часть pnpm dev
docker/docker-compose.observability.yml поднимает Prometheus, Loki, Alloy и
Grafana. Он не входит в pnpm dev, потому что это четыре лишних контейнера на
машине, где уже работают Postgres, Ollama и три приложения: тот, кто ничего не
расследует, не должен за них платить.
2. Метрики и журналы. Трассировка остаётся вне, и это решение
ADR 0026 (решение 9) установил, что трассировка идёт через OpenTelemetry Collector, а метрики и журналы не идут — метрика собирается scrape, журнал читается из stdout. Этот overlay реализует две, которым не нужен коллектор.
Трассировка потребовала бы Collector + Tempo, а ADR 0035 уже разделил инструментирование и
экспорт именно для того, чтобы отсутствие endpoint было нормальным состоянием при
разработке. Поэтому overlay не задаёт
OTEL_EXPORTER_OTLP_ENDPOINT: указание на несуществующий адрес порождало бы
ошибку экспорта на каждом ходе, что хуже, чем не экспортировать.
trace_id остаётся в журнале и по-прежнему связывает api и engine.
3. Артефакты — ТЕ ЖЕ, что в кластере, а не копия
Compose монтирует deploy/k8s/observability/dashboards/ напрямую, а UID
источников данных (brabo-prometheus, brabo-loki) идентичны UID в
grafana-values.yaml. Дашборд ссылается на источник данных по UID: расхождение здесь
вынудило бы ещё одну копию JSON, а копия дашборда расходится на первой
панели, которую кто-то исправит с одной стороны.
По той же причине scrape Prometheus выдаёт метки app и pod, по которым
группируют дашборды кластера, а сборщик журналов выдаёт метку app
(api/engine/web), которую Alloy кластера выводит из
app.kubernetes.io/name.
Новый дашборд этой поставки — Brabo — logs, с выбором сервиса и уровня, — рождается в том же каталоге, поэтому действует в обоих окружениях.
4. Разбор журнала — регулярным выражением, а не stage.json
В кластере Alloy делает stage.json, потому что в продакшене pino пишет одну строку
JSON на событие. При разработке pino-pretty рисует читаемое дерево
слоёв, а engine использует PrettyLogFormatter — намеренное и
хорошее решение, которое не меняют в угоду коллектору.
Поэтому подстраивается коллектор: очищает ANSI, извлекает уровень из обоих форматов (pino и Elixir) и нормализует регистр. Не меняются метки, которые являются контрактом с дашбордом.
5. В Loki идут только три приложения
api, engine и web. Postgres и Ollama — сторонняя инфраструктура с
объёмным журналом, и тому, кому они нужны, доступен docker compose logs.
Стек наблюдаемости остаётся вне по более жёсткой причине: он работает в ТОМ ЖЕ проекте Compose, поэтому без фильтра Loki поглощал бы собственный журнал — и журнал этого поглощения — в самоподпитывающемся цикле.
6. trace_id — структурированные метаданные, никогда метка
То же решение, что в кластере, и причина — кардинальность: метка на трассу
взрывает индекс Loki. Это требует allow_structured_metadata: true и схемы
v13, которых нет в конфигурации образа по умолчанию, — отсюда
собственный docker/observability/loki.yml.
Последствия
- Метрики и журналы становятся наблюдаемыми без кластера, и один дашборд обслуживает оба окружения.
- Локальный Grafana и Grafana кластера спорят за порт 3001. Это та же
несовместимость, что уже существует между
pnpm devиmake deploy-local, по той же причине, и она решается не сменой порта одного из двух, а тем, что их не запускают одновременно. - Alloy читает сокет Docker. Он смонтирован только для чтения, и это dev.
- Панель стоимости/токенов рождается пустой в базе без трафика: у этих метрик есть
метка, а в
prom-clientметрика с меткой не выдаёт серию до первого наблюдения. Это зафиксировано в runbook, чтобы не стать подозрением на сломанную панель.
Три режима тихого сбоя, которые стоили времени и стали комментарием в коде
Заслуживают записи, потому что сигнатура всех трёх одинакова — валидная конфигурация, ноль ошибок в журнале, неверные данные на панели:
stage.replaceбез группы захвата. Он заменяет группы, а не совпавший фрагмент. Без скобок регулярное выражение совпадает и ничего не происходит, и ANSI просачивается на панель.- Экранирование
\x1bв строке River. River обрабатывает escape-последовательности до компиляции регулярного выражения, так что число обратных слэшей превращается в гадание. POSIX-класс[[:cntrl:]]не зависит от этого слоя. - Шаблон Go с отсутствующим ключом отрисовывает буквальный текст
<NO VALUE>, который стал значением уровня в селекторе дашборда. Явныйelse(OUTRO) — то, что закрывает кардинальность.
Отброшенные альтернативы
- Поместить четыре сервиса в
docker-compose.yml: всегда включены, расходы для всех, польза для тех, кто расследует. - Скопировать дашборды в
docker/observability/: две копии, которые расходятся на первой панели, исправленной с одной стороны. - Сделать журнал разработки JSON, чтобы переиспользовать
stage.jsonкластера: обменяло бы повседневную читаемость для разработчика на удобство коллектора. Подстраивается коллектор. - Поднять Tempo и Collector вместе: собственная область, а ADR 0035 уже установил, что неэкспорт — нормальное состояние при разработке.