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).
- 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-shimingestion_job_events AS SELECT * FROM connector_eventsдля backward-compat readers. http.RoundTrippermiddlewareconnectortransport.Wrap(rt, opts)оборачивает HTTP transport каждого connector adapter. Читает Scope изcontext.Context(credential_ref / customer_ref / origin / job_id / source_mode), эмититconnector.http.request.endEvent через globalobservability.EventSink.PostgresJobHistorySink.persistтеперь работает сjob_id = NULL(live calls + diagnostic_probe persist’ятся без job-context).- Two-tier retention: 12h body NULL’ing + 30d row DELETE через
CleanupTickerс PG advisory lock (connector_events_cleanup). Metadata колонки сохраняются 30d для тренд-аналитики, body — 12h для post-mortem. - Body envelope:
{content_type, kind: json|text|binary_skipped, text|json, truncated, bytes_original}— всегда валидный JSONB независимо от content-type (HTML 5xx page, truncated stream, binary skipped). - Redaction в middleware: headers
Authorization/X-Api-Key/Cookie/Set-Cookie→<redacted>; query keys matchingsession-id/api_key/token/master_key/password/secret; JSON body walker recursive по тем же regex. - Sampling: response body capture при
status_code >= 400всегда + 1% sampling на success (configurable черезOptions.SampleOK). - Backend handler
GET /api/v1/admin/connector-eventsс 13 фильтрами + keyset cursor pagination (underAdminPathGate). - 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). - Event rename
ingestion.http.request.end → connector.http.request.endсделан с dual-read shim в trace SQL (backend/internal/platform/di/job_history.go:286) и frontenddiagnostics.ts:500— existing IngestionLogsTab не сломан.
Последствия
Плюсы
- Один store для всех supplier-call HTTP events: ingestion + live customer-flow + diagnostic. Admin может провалиться из KPI в конкретный запрос.
http.RoundTrippermiddleware — 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.endevent живёт параллельно с legacyingestion.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