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

ADR 0070 — Наблюдаемость в локальном Compose: метрики и журналы, без трассировки

  • Статус: принят
  • Дата: 2026-08-14
  • Расширяет: ADR 0026, ADR 0035

Контекст​

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, чтобы не стать подозрением на сломанную панель.

Три режима тихого сбоя, которые стоили времени и стали комментарием в коде​

Заслуживают записи, потому что сигнатура всех трёх одинакова — валидная конфигурация, ноль ошибок в журнале, неверные данные на панели:

  1. stage.replace без группы захвата. Он заменяет группы, а не совпавший фрагмент. Без скобок регулярное выражение совпадает и ничего не происходит, и ANSI просачивается на панель.
  2. Экранирование \x1b в строке River. River обрабатывает escape-последовательности до компиляции регулярного выражения, так что число обратных слэшей превращается в гадание. POSIX-класс [[:cntrl:]] не зависит от этого слоя.
  3. Шаблон Go с отсутствующим ключом отрисовывает буквальный текст <NO VALUE>, который стал значением уровня в селекторе дашборда. Явный else (OUTRO) — то, что закрывает кардинальность.

Отброшенные альтернативы​

  • Поместить четыре сервиса в docker-compose.yml: всегда включены, расходы для всех, польза для тех, кто расследует.
  • Скопировать дашборды в docker/observability/: две копии, которые расходятся на первой панели, исправленной с одной стороны.
  • Сделать журнал разработки JSON, чтобы переиспользовать stage.json кластера: обменяло бы повседневную читаемость для разработчика на удобство коллектора. Подстраивается коллектор.
  • Поднять Tempo и Collector вместе: собственная область, а ADR 0035 уже установил, что неэкспорт — нормальное состояние при разработке.