1. Simple Z-Score Methodology
For each(project_id, connector, metric, date) tuple, toorow computes a standard z-score:
rolling_meanandrolling_stddevare calculated over a trailing window of 30 rows, not 30 days (ANOMALY_BASELINE_WINDOW=29, i.e.ROWS BETWEEN 29 PRECEDING AND CURRENT ROW). The frame counts rows present in the series, so a series with gaps — a connector that skipped days, a metric that only appears on active days — stretches the same 30-row window over 40 or more calendar days. Nothing signals that stretch; if you need a calendar guarantee, the frame would have to beRANGE, and it is not.- The window includes the observation being tested: it is in its own mean and in its own sample standard deviation. See Minimum evidence before the detector can speak below — this bounds what the z-score can reach.
- Source data is sourced from
fact_daily_kpitotals using single-canonical-dimension selection to prevent double-counting.
dbt/models/marts/metric_baselines.sql and dbt/models/marts/anomalies_daily.sql.
1b. Minimum evidence before the detector can speak
Because the tested observation sits inside its own mean and its own sample standard deviation, the attainable z-score is bounded:n is the number of observations in the frame. The consequence is not statistical subtlety, it is arithmetic: below a certain n, no data whatsoever can reach the configured threshold. A ×10 spike on day 5 is mathematically incapable of firing.
The bound is stated here; the number is not hardcoded in this document. It is derived from the configured threshold by core.anomaly_alerts.minimum_observations_for_threshold() — the smallest n such that (n-1)/sqrt(n) >= ANOMALY_Z_THRESHOLD. Editing the environment variable moves the number, and every surface that discloses it follows.
metric_baselines therefore carries observation_count, computed over the identical frame as the mean and the standard deviation. It exists on every row of the mart, including the days nothing fires — which is exactly where the state has to be readable, because anomalies_daily keeps only rows that already cleared the threshold.
A surface reading this data must separate two answers that look identical on anomalies_count:
The window is deliberately not re-specified to exclude the tested point: doing so would change detection sensitivity and add firings. This bound is disclosed, not engineered away.
2. Anti-Double-Count Guard
fact_daily_kpi contains parallel breakdown dimension series (e.g. device_category and country) that independently total the day’s activity. Summing across all breakdown rows directly would produce values 2–5x too large.
metric_baselines.sql enforces the canonical_dim pattern to select ONE breakdown dimension per (project, connector, metric) deterministically (MIN(breakdown_dimension)), aggregating exclusively over that dimension’s rows.
3. Thresholds & Severity Levels
An anomaly is flagged whenever|zscore| >= ANOMALY_Z_THRESHOLD (default 3.0):
4. Context Event Citations
Anomalies never assert causality. When context events exist for an anomaly date, they are listed strictly as candidate context. Prohibited causal phrases in generated messages:"caused by","due to","because of","en raison de","causé par".
TestCausalLanguageAbsent in server/tests/core/test_anomaly_alerts.py, which asserts none of these patterns appears in any generated message.
5. Environment Variables
Next Steps & Cross-References
Agentic Daily Insights
How a firing reaches the daily insight, and the context offered beside it.
Semantic Layer
Explore the canonical marts providing baseline metric data.
Security & Constraints
Review how context is offered without asserting a cause.
Self-Hosting Guide
Configure
ANOMALY_ALERTS_ENABLED in your .env deployment environment.
