Skip to main content

Activity Monitor event-sourcing architecture

Why this exists

The original D2 receiver implementation (commits 1abae1676, 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

The shape mirrors 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 a TraceSummaryData 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:
  1. 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).
  2. trace_summaries grows a discriminator column and the fold projection becomes branchy.
Both make trace-processing harder to reason about and add coupling between independently-evolving subsystems. A dedicated activity-monitor-processing pipeline keeps each surface’s aggregate semantics clean.

Aggregate identity

The fold projection (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.feature updated to drop poller language; subscriber framing throughout.
  • AnomalyAlert Prisma model + migration 20260427020000_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: ActivityEventReceived with the OCSF-normalised ActivityEventRow shape.
  • New command: RecordActivityEventCommand wired into the pipeline.
  • Refactor /api/ingest/otel/:sourceId and /api/ingest/webhook/:sourceId to call the command instead of writing CH directly.
  • Map projection activityEventStorage writes to gateway_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 anomalyWindow fold projection (per-tenant rolling totals).
  • Add anomalyDetection subscriber for spend_spike only 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 /governance within ~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_events CH schema (migration 00019_*): unchanged. The map projection writes the same columns.
  • OTel and webhook normalisers (normalizers/otel.ts etc.): 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

Last modified on August 23, 2026