Skip to main content
This document describes the anomaly surveillance methodology implemented in toorow.

1. Simple Z-Score Methodology

For each (project_id, connector, metric, date) tuple, toorow computes a standard z-score:
Where:
  • rolling_mean and rolling_stddev are 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 be RANGE, 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_kpi totals using single-canonical-dimension selection to prevent double-counting.
The exact SQL transformations are implemented in 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:
where 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".
Enforced by 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.