Newer
Older
gn-synapse / docs / 05-ingestion-api.md

05 — Ingestion API: контракт приёма событий (v1, принят)

Договор о том, как сервисы экосистемы (bugtrail, gntodo, smart-home, Navi-инстансы, gnexus-auth, ad-hoc скрипты) шлют события в Synapse.

Инвариант

Источник знает только «что случилось у меня». В конверте события нет получателей, каналов и подписок — выбор «кому, куда и каким каналом» целиком принадлежит Routing Engine (правилам в админке). Клиент, который «знает про других клиентов» — признак ошибки проектирования. Когда-то понадобятся адресные алерты конкретному человеку (например assignee bugtrail) — путь через payload-переменную в шаблоне ({{ payload.assignee_email }}), не через поля конверта.

Endpoint

POST /api/v1/events
Authorization: Bearer syn_<token>       # единый паттерн экосистемы (Bearer)
Content-Type: application/json

Для пачек: POST /api/v1/events/batch — тот же конверт в массиве, ответ — список id (частичная валидация честная).

Конверт

{
  "source": "monitoring",
  "subject": "container",
  "action": "down",
  "priority": "critical",
  "payload": { "container": "nomin-web", "host": "melody", "exit_code": 137 },
  "dedup_key": "container-nomin-web-down-2026-10-03",
  "ttl_seconds": 3600,
  "scheduled_at": null
}
Поле Обяз. Смысл
source да Явное имя источника (см. «Источник и ключ»). Правила маршрутизации матч имя, не токен — ротация ключа не трогает правила.
subject да ЧТО: сущность события — issue, container, build, motion…
action да Что с ней случилось: created, down, resolved…
priority нет `low \ normal \ high \ critical, дефолтnormal. Фиксированная шкала, не число.critical` в будущем = обход шумодавов, отдельная очередь.
payload нет Любой JSON. Опциональная JSON Schema типа enforced'ится воркером (не на приёме). Шаблоны каналов должны быть устойчивы к отсутствию полей.
dedup_key нет Ретраи источника: тот же ключ в TTL-окне → deduplicated: true, id: <первый>. Дубли в каналах раздражают сильнее всего.
ttl_seconds нет Событие «контейнер упал» бесполезно через час: воркер не маршрутизирует истёкшее (статус expired), ретеншн удалит (см. «Ретеншн»). Дефолт — «вечно», поле обязательно с первого дня — ретрофит дороже.
scheduled_at резерв Не реализуется в MVP; поле зарезервировано.

Не существует и не появится в клиентском контракте: recipients, topics, channel — получатели/каналы/подписки живут только внутри Synapse (правила в админке). tags из обсуждений убраны из v1; вернутся, когда появится реальное правило, требующее тега (решение обсуждалось 2026-10-03).

Конвенция payload.user_id (соглашение 2026-10-03)

Если событие связано с конкретным пользователем, источник кладёт его id gnexus-auth (sub) в payload.user_id — это часть «что случилось», а не получатель: конверт по-прежнему ничего не знает о получателях. Что Synapse делает с этой привязкой — решают правила: событие попадает в личный лог пользователя (/api/v1/me/events) только если подошло правило маршрутизации с действием канала user (цель не задаётся); правила без такого действия — «событие только для админа». Канал push (web-push, docs/06) работает по той же семантике — доставляется в подписки пользователя из payload.user_id (см. так же открытый вопрос №3 в CLAUDE.md: привязка tg chat_id/email остаётся открытой).

Формат значения: непустая строка (число тоже примем — приведётся к строке). Всё прочее (массив, объект, bool) — при маршрутизации записывается как skipped с причиной в /admin/deliveries: доставка «в никуда» обязана быть видимой, не тихой. Внутри s2s-тела payload уходит как есть — получатель-сервис получает payload.user_id без специальных полей.

Ответы

Код Когда
401 Нет/битый ключ
403 Ключ валиден, но не принадлежит source из тела (анти-спуфинг)
422 Неизвестная тройка (source, subject, action) — «тип не зарегистрирован». Payload при приёме не валидируется — deep-валидация в воркере, её исход — Delivery Log, не откат приёма.
202 Принято → {"id": "uuid", "status": "queued", "deduplicated": false}

Валидация конверта жёсткая и мгновенная, payload — мягкая. Схема «принял мгновенно → разберусь» ломается, если требовать на входе знание всех payload-схем.

Статус события

GET /api/v1/events/{id} — только источнику события, чужой id → 404:

{
  "id": "uuid", "status": "delivered",
  "deliveries": [
    {"channel": "telegram", "target": "infra-squad", "status": "delivered", "attempts": 1, "error": null},
    {"channel": "internal_log", "status": "delivered", "attempts": 1}
  ]
}

Обратный канал (webhook об изменении статуса) позже; контракт поллинга стабилен — подписки докинутся сверху. Для MVP источники либо fire-and-forget, либо поллят этот GET.

Типы и правила (админка) — Routing Engine

Реестр типов

Тройка (source, subject, action), опционально JSON Schema payload'а (валидация мягкая, в воркере). Заводит админ Synapse в UI.

Правило маршрутизации

Условия (conditions JSONB):

{
  "source": "monitoring",            // null/отсутствует = любой источник
  "subjects": ["container"],         // [] = любые
  "actions": ["down", "restarting"], // [] = любые
  "priority_min": "high",            // шкала low/normal/high/critical
  "payload": {"container": "nomin-web"}  // опц.: точный матч по top-level
                                         // ключам payload'а; значение-список
                                         // = any-of («хост melody или pilar»)
}

Действия — по одному на строку: канал + цель (telegram:infra-alerts, internal_log, позднее email, s2s:navi-rei) + опциональный шаблон (Jinja2, {{ payload.x }}; шаблон действия переопределяет шаблон правила). Шаблон обязан быть устойчив к отсутствию полей payload.

Каналы без цели: internal_log (журнал Synapse), user (личный лог пользователя — адресат из payload.user_id; событие без этого поля пишется как skipped с причиной, админ видит её в /admin/deliveries) и push (web-push в подписки того же адресата; без payload.user_id — skipped, без VAPID/подписок — pending с ошибкой и ретраями). Событие попадает пользователю в личный лог (/api/v1/me/events) только через правило с действием user.

Сколько правил применяется

Задаёт настройка ROUTING_MATCH_MODE:

  • all (дефолт) — все подошедшие правила; каждое добавляет свои доставки. «Контейнеру вниз → TG», «critical → Navi» и «всё от monitoring → лог» работают одновременно.
  • first — только первое по weight (меньше — раньше). Суровые сценарии «одно правило на тип».

Шумодав (throttle_seconds)

Правило может ограничить частоту: если в ту же цель (channel, target) уже была создана доставка (pending/delivered) за последние N секунд — новая записывается со статусом skipped (аудит: rendered_message хранит, что отправили бы). Окно продлевают только реальные доставки — непрерывный шторм срезанный не продлевает. priority: critical проходит шумодав всегда — это часть смысла критичности (обещано в конверте). low-интервалы типа «контейнер перезапускается 20 раз» — тот же механизм.

Ретраи доставок

Провал доставки (сеть, 5xx провайдера) → обратно-экспоненциальная пауза: 30 c → 2 м → 10 м → 30 м, всего 5 попыток → failed. Скан due-доставок — Celery beat раз в минуту (synapse.retry_due). Пока канал не реализован (#29/#28), доставки честно проходят этот цикл и падают в failed — таблица сразу показывает поведение ретраев.

Ретеншн

Удаление — beat-задачей synapse.expire_events (ежечасно):

  • событие с истёкшим ttl_seconds воркер не маршрутизирует (статус expired), ретеншн удаляет его; доставки падают каскадом;
  • события старше RETENTION_DAYS (дни, дефолт 30) удаляются независимо от ttl; 0 — окно по возрасту отключено (ttl продолжит работать).

Следствие: /api/v1/me/events и разделы админки читают текущее состояние, а не архив — удалили событие, пропали и записи о нём (включая личный лог канала user).

Доставка s2s: подпись и верификация

Когда правило направляет событие в системный webhook (Navi и другие сервисы), Synapse подписывает доставку. Схема — ровно та же, что у вебхуков gnexus-auth (WebhookSignature.php), так что принимающий код в экосистеме один и тот же.

Заголовки (как у gnexus-auth, плюс один):

X-Gnexus-Event-Id: <uuid события Synapse>
X-Gnexus-Event-Type: monitoring.container.down      # "<source>.<subject>.<action>"
X-Gnexus-Event-Timestamp: 1759483200
X-Gnexus-Signature: t=1759483200,v1=<hex>
X-Synapse-Source: monitoring                        # доп. заголовок Synapse

Подпись — HMAC-SHA256 по «сырому телу» запроса (то, что уйдёт в сеть, байт в байт):

sig = "t=" + unix_timestamp + ",v1=" + hex(hmac_sha256(unix_timestamp + "." + raw_body, secret))

Проверка на принимающей стороне:

  1. Вычислить ожидаемую строку t=…,v1=… из raw body (как оно пришло, до каких-либо парсингов) и своего секрета.
  2. Сравнить через константное время (PHP hash_equals, Python hmac.compare_digest) — не через ==.
  3. Свежесть: |now - t| в пределах допуска (gnexus-auth — 5 минут) → защита от replay.

Секрет — свой у каждой цели (per-target, поле token_ref в channel_targets.config), значения в .env/gnexus-creds (переменная S2S_SECRET_<token_ref в UPPER_SNAKE>), в БД только ссылки. Ротация секрета цели не затрагивает правила маршрутизации.

Цепочка доверия: получатель проверил подпись ⇒ целостность и авторство Synapse. Синтезировать чужое событие Synapse не может — на приёме его ключ и source сверились бы с реестром (403), а ретрансляция чужого ключом источника невозможна, поскольку событие должно нести source, совпадающий с ключом (анти-спуфинг).

Тело s2s-доставки — конверт события без изменений (описание источника в него не входит):

{
  "event_id": "uuid",
  "source": "monitoring",
  "subject": "container", "action": "down", "priority": "critical",
  "payload": { "container": "nomin-web", "host": "melody" }
}

description — атрибут записи источника в реестре Synapse (админка), а не поля события: конверт и s2s-вебхук его не несут. Получатель видит имя источника в X-Synapse-Source; зачем оно — смотрит в админке Synapse, где у каждого источника/цели прописано описание («что это за сервис» для человека и ИИ-агента, не гадать по названию).

Отправка: первая попытка — сразу при маршрутизации события в воркере; провал → общий ретрай-цикл (30 с → 2 м → 10 м → 30 м). Референс-код Synapse: app/signature.py (подпись/проверка — зеркало php-класса выше), app/worker/senders.py (отправщик). Эту пару модулей можно заимствовать в принимающем сервисе (только Python; Navi-инстансы на другом стеке — по этой же спецификации).

Таблицы БД (постановка #34)

api_keys (хэш, привязка к source) · notification_types(source, subject, action) · routing_rules (условия, действия, шаблон) · events (конверт + payload + статус) · deliveries (канал, цель, статус, попытки, ошибки) · channel_targets.