Activity Monitor event-sourcing architecture
Why this exists
The original D2 receiver implementation (commits1abae1676,
92e515cc2) wrote OCSF-normalised events directly into ClickHouse
gateway_activity_events from the Hono receiver handler, and the
plan for anomaly detection (Option C v0) was a poller worker that
periodically swept active AnomalyRule rows and SELECT-ed against
the CH table.
We redesigned the trigger architecture before the eval engine landed,
following the event-sourcing pattern from
PR #3351. The
receiver appends an ActivityEventReceived event to event_log, and
a dedicated activity-monitor-processing pipeline takes over from
there. Anomaly detection is a subscriber that fires as new events
arrive, not a worker that polls.
The pipeline
pipelines/trace-processing/ (PR #3351’s
alertTrigger subscriber): the same definePipeline().withFoldProjection(). withMapProjection().withSubscriber() builder, the same
SubscriberDispatchDefinition<EventShape, FoldState> contract, the same
triggerActionDispatch.ts shared helper.
Why a dedicated pipeline (not bolted onto trace-processing)
Gateway and activity events have different aggregate semantics from traces. A trace is a multi-span aggregate that folds into aTraceSummaryData over its lifetime. An activity event is a single
completed observation of upstream platform behaviour, with no
multi-event aggregate to fold across; each event already has
final cost, tokens, and actor when it arrives.
Bolting them onto trace-processing would force one of:
- Activity events get represented as fake single-span traces (lossy and confusing: trace_summaries would mix gateway-proxied traces and per-event activity rows under the same TenantId).
- trace_summaries grows a discriminator column and the fold projection becomes branchy.
activity-monitor-processing pipeline keeps each surface’s
aggregate semantics clean.
Aggregate identity
anomalyWindow) aggregates across aggregates
within a tenant, keyed by tenant and rolling window, rather than
folding events into one aggregate. Trace-processing folds spans
into a trace summary; this fold tallies per-tenant rolling spend,
request count, and per-actor breakdown for the past N minutes or
hours. Same machinery, different aggregate semantics.
Slicing the redesign
The redesign ships as four slices, C0 through C3:C0: this doc and spec updates
- This architecture doc.
specs/ai-gateway/governance/anomaly-detection.featureupdated to drop poller language; subscriber framing throughout.AnomalyAlertPrisma model + migration20260427020000_add_anomaly_alert/doc-comment updated to reference the subscriber as producer.- Existing receivers continue to write CH directly until C1 lands. This slice is doc-only so the team can review the architecture before more code moves.
C1: receiver → event_log → projection subscriber
- New event schema:
ActivityEventReceivedwith the OCSF-normalised ActivityEventRow shape. - New command:
RecordActivityEventCommandwired into the pipeline. - Refactor
/api/ingest/otel/:sourceIdand/api/ingest/webhook/:sourceIdto call the command instead of writing CH directly. - Map projection
activityEventStoragewrites togateway_activity_events(replaces today’s direct insert). - Manual check: curl → 202 → row visible in CH (same as today, just via event-sourced path).
C2: AnomalyAlert and anomaly subscriber for one rule type
- Apply the AnomalyAlert migration that’s already drafted but doesn’t ship behaviour yet.
- Add
anomalyWindowfold projection (per-tenant rolling totals). - Add
anomalyDetectionsubscriber forspend_spikeonly first (cleanest mapping to the existing CostUSD field). - Wire into
api.activityMonitor.recentAnomalies(replaces current[]stub). - Manual check: create a rule in the anomaly rules UI → curl a violating
event → alert appears on
/governancewithin ~30s.
C3: Dispatch destinations
- Generic webhook + log-only first (matches PR #3351’s triggerActionDispatch shape).
- Slack / PagerDuty / SIEM / email follow as per-destination adapter slices once the subscriber pattern is proven.
What we keep from the v0 receiver code
IngestionSourceService(CRUD and auth): unchanged.gateway_activity_eventsCH schema (migration00019_*): unchanged. The map projection writes the same columns.- OTel and webhook normalisers (
normalizers/otel.tsetc.): unchanged. The map projection calls them now instead of the receiver handler. - All receiver auth, sourceId-mismatch, and 24h secret rotation grace: unchanged.
What we drop from the v0 receiver code
- The direct
ActivityEventRepository.insert(...)call from the receiver handler. The receiver instead enqueues an event into the pipeline; the map projection does the actual CH insert. - The poller-based AnomalyEvaluatorService design that was sketched but never shipped. Replaced by the anomaly subscriber.
Test strategy per slice
Each slice ships its own BDD and integration coverage before code
lands. The production architecture is subscriber-only:
evaluateNow
appends a synthetic event and lets the subscriber handle it (a test
harness, not a parallel code path).
Cross-references
- PR #3351: event-driven trace triggers via subscriber (the pattern this redesign learns from).
anomaly-detection.feature: user-facing contract, updated for event-sourcing.anomaly-rules.feature: configuration entity (already shipped, unchanged).activity-monitor.feature: admin UI contract (already shipped; pipeline section adds in C1).- Governance architecture: top-level governance architecture. This doc is the activity-monitor deep-dive linked from the “Activity Monitor (Tier C/D)” block.