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).
Требования к циклу:
- Prometheus-совместимый
/metricsво всех 5 бинарниках — без изменений domain-интерфейсов. - Runtime метрики Go (GC, goroutines, memory) — бесплатно, без ручной инструментации.
- Resource атрибуты (
service.name,deployment.environment, etc.) — единожды, централизованно. - Cardinality-контроль: allowlist атрибутов с lint-guard в CI.
- Миграционный путь на OTLP push в будущем без переписывания domain-кода.
- Атомарность замены: сломанный 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-server | 9095 |
| supplier-sync | 9091 |
| charnorm-worker | 9092 |
| canonical-assignment-worker | 9093 |
| matcher-worker | 9094 |
Порты не пересекаются с основным 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-кодом.
Последствия
Плюсы:
/metricsendpoint 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.Replaceper 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.go—NewMeterProvider, resource attrs, runtime contrib, otelprom exporterexporter_smoke_test.go— проверка/metricsHTTP 200 + наличие имён метрик
backend/internal/core/<bc>/domain/metrics_otel.go (10 BC):
Otel<X>Metricsstruct +<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.Extrahook)