ADR-0048: Prometheus метрики через OpenTelemetry SDK + otelprom Pull-exporter

Status: accepted Date: 2026-04-30 Deciders: agent-claude

Контекст

До этого цикла каждый bounded context реализовывал метрики через Atomic*Metrics — структуру с sync/atomic счётчиками и slog-mirror: каждый инкремент писался в лог параллельно обновлению счётчика. Endpoint /metrics во всех бинарниках возвращал заглушку.

Это было обоснованным решением на ранних фазах: slog-mirror обеспечивал наблюдаемость через log aggregator без зависимости от Prometheus SDK. Однако по мере роста числа процессов (5 long-running бинарников: api-server, supplier-sync, charnorm-worker, canonical-assignment-worker, matcher-worker) и числа доменных метрик (~30+ счётчиков, гистограмм, gauge) отсутствие scrape-эндпоинта стало soft-блокером для production-запуска (см. tracium_path_to_launch).

Требования к циклу:

  1. Prometheus-совместимый /metrics во всех 5 бинарниках — без изменений domain-интерфейсов.
  2. Runtime метрики Go (GC, goroutines, memory) — бесплатно, без ручной инструментации.
  3. Resource атрибуты (service.name, deployment.environment, etc.) — единожды, централизованно.
  4. Cardinality-контроль: allowlist атрибутов с lint-guard в CI.
  5. Миграционный путь на OTLP push в будущем без переписывания domain-кода.
  6. Атомарность замены: сломанный OTel-impl в одном BC откатывается без затрагивания остальных.

Решение

Выбор SDK

Принят OpenTelemetry Go SDK (go.opentelemetry.io/otel) для сбора метрик + экспортер go.opentelemetry.io/otel/exporters/prometheus (otelprom) для pull-mode экспозиции через promhttp.Handler. Contrib-пакет go.opentelemetry.io/contrib/instrumentation/runtime добавляет process_runtime_go_* метрики автоматически.

Loopback HTTP для /metrics

Каждый бинарник выставляет /metrics на отдельном loopback-порту через loopbackhttp.Server (существующий инфраструктурный компонент, ADR-0046):

БинарникПорт
api-server9095
supplier-sync9091
charnorm-worker9092
canonical-assignment-worker9093
matcher-worker9094

Порты не пересекаются с основным HTTP/gRPC api-server (8080/8081) и health-портами. Loopback-ограничение исключает внешний доступ к /metrics без явного проброса через nginx.

Composition: fx.Replace per binary

Каждый BC регистрирует Atomic*Metrics как дефолтный биндинг через свой di.go. Production-бинарники перекрывают его через fx.Replace(<bc>.OtelMetricsModule()) в cmd/<binary>/main.go. Тестовый и dev-код продолжает получать Atomic реализацию без изменений.

Правило: cmd/<binary>/main.go содержит только lifecycle-вызовы и composition (ADR-0030). OtelMetricsModule() — это fx.Module с fx.Provide + fx.Replace; никакой бизнес-логики.

Otel*Metrics impl рядом с Atomic

10 BC получили парные Otel<X>Metrics реализации в файлах metrics_otel.go рядом с существующими metrics.go (Atomic). Интерфейс <BC>Metrics не изменился — единственная точка изменения в domain это имена методов уже существующего интерфейса.

MeterProvider — централизованная инициализация

backend/internal/platform/observability предоставляет NewMeterProvider(cfg Config) (*sdkmetric.MeterProvider, error):

  • Регистрирует otelprom exporter.
  • Устанавливает resource атрибуты (service.name, service.version, service.instance.id, deployment.environment) через sdkresource.WithAttributes единожды.
  • Запускает runtime.Start() contrib для Go runtime метрик.
  • Регистрирует promhttp.Handler в переданный *http.ServeMux (loopback).

Каждый бинарник передаёт Config{ServiceName: "tracium-<binary>", ...} при старте через fx.

Соглашения по именованию и меткам

Именование метрик

ТипОбъявление в кодеФинальное имя в Prometheus
Counter"ingestion_ticks"ingestion_ticks_total
Histogram"live_fetch_duration" + WithUnit("s")live_fetch_duration_seconds
Gauge"ingestion_last_success_age" + WithUnit("s")ingestion_last_success_age_seconds

OTel-соглашение: суффикс _total и _seconds добавляет otelprom exporter автоматически. Код не дублирует суффикс в имени инструмента.

Meter scope

tracium/<bc> — например tracium/proposal/live, tracium/visibility, tracium/ingestion, tracium/exchange, tracium/canonical. Scope определяет namespace в target_info.

Buckets гистограмм

Границы бакетов объявлены как экспортируемые var в metrics_otel.go каждого пакета (например IngestionTickDurationBuckets = []float64{1, 5, 15, 30, 60, 120, 300, 600, 1800, 3600}). Это позволяет тестам и runbook’у ссылаться на конкретные значения без магических констант.

Cardinality: allowlist атрибутов

Запрещены суффиксы _id, _ref, _uuid, _key в именах атрибутов — они несут unbounded cardinality. Единственное исключение из allowlist: handler_ref (реестр pricing handlers, ADR-0039) — bounded enum по определению.

Lint-guard: backend/scripts/lint-metrics-labels.sh — grep-based скрипт, запускается в CI. Ловит attribute.String("..._id"...) и аналогичные вызовы вне allowlist.

Sync gauges (age-метрики)

ingestion_last_success_age_seconds и exchange_last_refresh_age_seconds реализованы как caller-driven sync gauge (Record(d.Seconds())), а не observable callback.

Мотивация: тик происходит редко (минуты–часы); callback вызывался бы каждые 15s scrape и требовал доступа к состоянию из closure. Caller вычисляет time.Since(lastSuccess) в момент успешного завершения тика и записывает в gauge. Scrape читает последнее записанное значение.

slog-mirror сохраняется в Otel-impl

Otel<X>Metrics сохраняет slog-mirror рядом с OTel-вызовом. Атрибут metric: содержит каноническое Prometheus-имя с суффиксом (ingestion_ticks_total, live_fetch_duration_seconds). Это даёт log-aggregator читателю тот же контракт что Prometheus scrape — без зависимости от scrape-интервала и без дублирования смысловой нагрузки в лог-сообщении.

Принятые ограничения

  • Atomic*Metrics живут параллельно с Otel*Metrics. Двойная инфра-стоимость принята: Atomic нужен для тестов без OTel SDK + slog-mirror работает независимо от scrape интервала.
  • Только pull-mode (scrape). OTLP push не реализован в этом цикле — добавляется отдельным экспортером без изменений domain-кода.
  • Нет агрегации из нескольких инстанс. Один pod = один /metrics. Агрегация — на стороне Prometheus federation или Victoria Metrics (infra-scope).
  • Smoke-тест, не e2e. exporter_smoke_test.go проверяет, что /metrics возвращает HTTP 200 и содержит ожидаемые имена метрик. Полная интеграция с Prometheus — в staging окружении.

Альтернативы, отклонённые

  • prometheus/client_golang напрямую. Меньше транзитивных зависимостей, хорошо известный API. Отклонено: нет миграционного пути на OTLP push без переписывания domain-инструментации. OTel оставляет дверь открытой: достаточно добавить otlpmetricgrpc.New(...) экспортер рядом с otelprom без правок вне platform/observability.

  • Wrapper-фасад поверх обоих SDK. Позволил бы менять backend незаметно для BC-кода. Отклонено: интерфейс <BC>Metrics уже служит этой абстракцией; третий слой добавляет сложность без выгоды.

  • Только OTLP push (без pull). Чище архитектурно, не требует loopback HTTP. Отклонено: pull-model проще для текущей dev/prod-инфраструктуры (Prometheus + nginx), не требует collector-sidecar. Push добавляется в будущем не вместо, а вместе.

  • Observable gauge через callback. Подходит для метрик с мгновенным состоянием (текущий размер очереди). Для age-метрик требует closure с состоянием и мьютексом. Отклонено в пользу caller-driven Record — проще, не несёт скрытого coupling между scrape-goroutine и domain-кодом.

Последствия

Плюсы:

  • /metrics endpoint live во всех 5 бинарниках: runtime Go + target_info + domain метрики.
  • Free process_runtime_go_* метрики (GC pause, goroutine count, heap) через runtime contrib.
  • Cardinality lint-guard ловит attribute.String("..._id"...) вызовы в CI до попадания в prod.
  • Cycle 3 LLM-resolution, DKC adapter и любой будущий цикл пишут метрики в готовый *observability.MeterProvider без переделывания observability-инфраструктуры.
  • Добавление OTLP push в будущем — один экспортер в platform/observability, ноль правок в BC.
  • fx.Replace per binary: сломанный OTel-impl в одном BC откатывается удалением одной строки в cmd/<binary>/main.go без затрагивания других процессов.

Минусы / accepted trade-offs:

  • Один extra dep на runtime contrib (умеренный размер, Apache 2.0).
  • Atomic*Metrics + Otel*Metrics — двойная инфра-стоимость кода. Принято: Atomic нужен для тестов, slog-mirror независим от scrape.
  • Caller-driven sync gauge требует, чтобы caller знал момент успешного события. Если caller не вызвал Record — gauge хранит предыдущее значение до следующего события. Runbook описывает интерпретацию stale gauge.

Откат

Полный откат (pilot + sweep коммиты): git revert возвращает систему на slog-only Atomic*Metrics поведение. MeterProvider + loopback handler остаются (foundation phase), /metrics возвращает только runtime + target_info — degraded but functional.

Точечный откат одного BC: удалить строку fx.Replace(<bc>.OtelMetricsModule()) из cmd/<binary>/main.go — пакет откатывается на Atomic дефолт без затрагивания других бинарников или BC.

Реализация

Затронутые пакеты:

backend/internal/platform/observability/:

  • provider.goNewMeterProvider, resource attrs, runtime contrib, otelprom exporter
  • exporter_smoke_test.go — проверка /metrics HTTP 200 + наличие имён метрик

backend/internal/core/<bc>/domain/metrics_otel.go (10 BC):

  • Otel<X>Metrics struct + <BC>OtelMetricsModule() fx.Option
  • Exported bucket vars (где применимо)

backend/cmd/<binary>/main.go (5 бинарников):

  • fx.Replace(<bc>.OtelMetricsModule()) + observability.NewMeterProvider(cfg)

backend/scripts/lint-metrics-labels.sh — cardinality lint, подключён в CI

Связанные документы

  • Spec: docs/superpowers/specs/2026-04-30-prometheus-metrics-exporter-design.md
  • Plan: docs/superpowers/plans/2026-04-30-prometheus-metrics-exporter.md
  • Runbook: docs/operations/observability-metrics.md
  • Smoke test: backend/internal/platform/observability/exporter_smoke_test.go
  • Cardinality lint: backend/scripts/lint-metrics-labels.sh
  • ADR-0030 — Backend DI rule (main.go = lifecycle only, fx composition)
  • ADR-0043 — Ingestion orchestration (advisory lock, loopback pattern)
  • ADR-0046 — Canonical assignments foundation (loopbackhttp.Server.Extra hook)