ADR-0057: Unified connector_events log

Status: accepted Date: 2026-05-14 Deciders: Maxim Belkanov

Контекст

До этого цикла observability supplier HTTP-вызовов была фрагментированной:

  • ingestion_job_events хранила phase события, ограниченные context’ом job (PostgresJobHistorySink.persist дропал event без job_id).
  • Live customer-flow refresh (proposal pricing/stock/delivery через live_offer_reader.go и live_price_fetcher.go) логировался только slog-counters’ами в AtomicLiveMetrics без PG-persistence.
  • 5 connector adapters (etm/iek/systeme/dkc/russvet) вызывали http.Client.Do() без единой точки инструментации.
  • diagnostic_probe (admin UI «Проверка») не имел backend handler’а вообще.

Результат: admin не мог провалиться из KPI tile в конкретный HTTP-запрос с ключом X, не мог отладить parsing-баг по response body, не имел единого списка всех supplier-calls вне jobs.

Решение

Единый store connector_events для всех supplier HTTP-вызовов (jobs + live + diagnostic).

  1. Migration 0134 переименовала ingestion_job_events → connector_events, добавила 8 scope-колонок: supplier_ref, credential_ref, customer_ref, origin, status_code, error_code, request_body (envelope JSONB), response_body (envelope JSONB). 10 индексов (single + composite + partial cleanup). VIEW-shim ingestion_job_events AS SELECT * FROM connector_events для backward-compat readers.
  2. http.RoundTripper middleware connectortransport.Wrap(rt, opts) оборачивает HTTP transport каждого connector adapter. Читает Scope из context.Context (credential_ref / customer_ref / origin / job_id / source_mode), эмитит connector.http.request.end Event через global observability.EventSink.
  3. PostgresJobHistorySink.persist теперь работает с job_id = NULL (live calls + diagnostic_probe persist’ятся без job-context).
  4. Two-tier retention: 12h body NULL’ing + 30d row DELETE через CleanupTicker с PG advisory lock (connector_events_cleanup). Metadata колонки сохраняются 30d для тренд-аналитики, body — 12h для post-mortem.
  5. Body envelope: {content_type, kind: json|text|binary_skipped, text|json, truncated, bytes_original} — всегда валидный JSONB независимо от content-type (HTML 5xx page, truncated stream, binary skipped).
  6. Redaction в middleware: headers Authorization/X-Api-Key/Cookie/Set-Cookie<redacted>; query keys matching session-id/api_key/token/master_key/password/secret; JSON body walker recursive по тем же regex.
  7. Sampling: response body capture при status_code >= 400 всегда + 1% sampling на success (configurable через Options.SampleOK).
  8. Backend handler GET /api/v1/admin/connector-events с 13 фильтрами + keyset cursor pagination (under AdminPathGate).
  9. Admin UI route /suppliers/requests (FilterBar + ConnectorEventsTable + EventDrawer envelope-aware) + deep-link wiring из 8 entry-points (AccountRow menu / ConnectorDetailKpiTiles / ErrorsTopBlock / ThroughputChart bucket / SuppliersScreen top tiles / IngestionLogsTab header).
  10. Event rename ingestion.http.request.end → connector.http.request.end сделан с dual-read shim в trace SQL (backend/internal/platform/di/job_history.go:286) и frontend diagnostics.ts:500 — existing IngestionLogsTab не сломан.

Последствия

Плюсы

  • Один store для всех supplier-call HTTP events: ingestion + live customer-flow + diagnostic. Admin может провалиться из KPI в конкретный запрос.
  • http.RoundTripper middleware — zero boilerplate в connector adapter’ах; добавление новых supplier’ов сводится к одному fx provider.
  • Two-tier retention сохраняет 30d trend-данных, но не раздувает БД body’ями (12h hard cap).
  • Body envelope гарантирует валидный JSONB при любом content-type без specialization в repo SELECT.

Минусы

  • Schema connector_events на 8 колонок шире чем оригинальный ingestion_job_events; index footprint +10 индексов.
  • Body capture добавляет ~10–20мс к p99 на live customer-flow с body-fit ≤16 KiB.
  • View-shim ingestion_job_events остаётся живой; drop’нется в отдельной migration после rename callers.
  • Two cleanup queries на cron-tick дают ~5–15s/час нагрузки на PG; mitigated batched + advisory lock.

Нейтральные последствия

  • connector.http.request.end event живёт параллельно с legacy ingestion.http.request.end (dual-read) для одного retention cycle; после 30d новых rows со старым именем не будет.

Рассмотренные альтернативы

Альтернатива A — новая таблица supplier_http_log + transport wrapper

Создать новую таблицу с нуля, оставить ingestion_job_events для job-events. Минус: дублирование между jobs/non-jobs events, lose existing IngestionLogsTab readers, +1 migration cycle для drop старой.

Альтернатива B — keep ingestion_job_events name + drop NOT NULL

Не переименовывать таблицу, просто разрешить job_id = NULL. Минус: имя вводит в заблуждение forever (live calls = non-job-events в таблице с именем «job_events»).

Альтернатива C — explicit LogEvent per Do() в adapter’ах

Без middleware, вручную писать observability.LogEvent("connector.http.request.end", ...) вокруг каждого c.http.Do(). Минус: boilerplate в 10 файлах, ломается при refactor connector кода, нет body capture без duplicated logic.

Ссылки

  • Spec: docs/superpowers/specs/2026-05-14-unified-connector-events-design.md
  • План: docs/superpowers/plans/2026-05-14-unified-connector-events.md
  • Migration: backend/migrations/0134_connector_events_rename_and_extend.sql
  • Middleware: backend/internal/platform/observability/connectortransport/
  • Admin route: web/admin/src/app/(shell)/suppliers/requests/page.tsx