Rolling z-Score — Measure anomalies against a recent dynamic baseline while preventing leakage and warm-up false positives
The z-score used a fixed mean and standard deviation over the entire period as its baseline. But when a series has a trend or a level shift, a fixed baseline quickly becomes stale. In practice, a moving z-score (rolling z) based on only the "most recent N days" is standard. The two keys are to exclude the current day from the baseline (do not evaluate a value against itself—avoid leakage) and to defer classification until enough baseline data has accumulated (min_periods).
AVG(amount) OVER (ORDER BY dt ROWS BETWEEN 3 PRECEDING AND 1 PRECEDING) -- "3 PRECEDING AND 1 PRECEDING" = use only the prior three days, excluding today -- With "... AND CURRENT ROW", today (the value being evaluated) would enter the baseline
1 PRECEDING instead of CURRENT ROW keeps today's value out of the baseline calculation. This prevents an anomalous value from inflating its own baseline mean and standard deviation and making itself look normal (an application of an explicit window-frame specification).From sensor_readings, calculate the moving average roll_avg and moving standard deviation roll_std over the prior three days (excluding today), then calculate z =(amount − roll_avg)/roll_std. Rows with fewer than three usable baseline days, n_base<3, must be held as 'warmup'; otherwise, classify |z|≥2 as 'anomaly' and smaller values as 'normal'. Return dt, amount, roll_avg, roll_std, z_score, status in ascending dt order. Round the average, standard deviation, and z-score to two decimal places.
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 96 |
| 2024-01-03 | 104 |
| 2024-01-04 | 100 |
| 2024-01-05 | 96 |
| 2024-01-06 | 200 |
| 2024-01-07 | 100 |
| 2024-01-08 | 96 |
| 2024-01-09 | 104 |
| dt | amount | roll_avg | roll_std | z_score | status |
|---|---|---|---|---|---|
| 2024-01-01 | 100 | NULL | NULL | NULL | warmup |
| 2024-01-02 | 96 | 100.00 | NULL | NULL | warmup |
| 2024-01-03 | 104 | 98.00 | 2.83 | 2.12 | warmup |
| 2024-01-04 | 100 | 100.00 | 4.00 | 0.00 | normal |
| 2024-01-05 | 96 | 100.00 | 4.00 | -1.00 | normal |
| 2024-01-06 | 200 | 100.00 | 4.00 | 25.00 | anomaly |
| 2024-01-07 | 100 | 132.00 | 58.92 | -0.54 | normal |
| 2024-01-08 | 96 | 132.00 | 58.92 | -0.61 | normal |
| 2024-01-09 | 104 | 132.00 | 58.92 | -0.48 | normal |
Detect Consecutive Anomalies — Use Gaps & Islands to group only runs of N consecutive anomalous days
A one-off anomaly (point anomaly) means something different from a persistent incident where the anomaly continues for several days. The latter is the issue that usually deserves a response. Gaps & Islands is the classic technique for grouping consecutive rows into one island. Its core property is that the difference from a sequence number stays constant only while rows are consecutive.
-- Sort anomalous days by date and assign a row number... dt: 01-05 01-06 01-07 01-10 01-11 rn: 1 2 3 4 5 dt-rn: 01-04 01-04 01-04 01-06 01-06 ← the value is constant within each consecutive run
From service_health (daily error rates), define error_rate > 5 as an anomalous day and extract only runs of at least three consecutive anomalous days (persistent incidents). For each run, calculate start_dt, end_dt, consecutive-day count run_days, and the maximum error rate peak_error. Return rows ordered by start_dt ascending.
| dt | error_rate |
|---|---|
| 2024-01-01 | 2 |
| 2024-01-02 | 8 |
| 2024-01-03 | 3 |
| 2024-01-04 | 2 |
| 2024-01-05 | 9 |
| 2024-01-06 | 7 |
| 2024-01-07 | 6 |
| 2024-01-08 | 1 |
| 2024-01-09 | 2 |
| 2024-01-10 | 10 |
| 2024-01-11 | 12 |
| 2024-01-12 | 3 |
| start_dt | end_dt | run_days | peak_error |
|---|---|---|---|
| 2024-01-05 | 2024-01-07 | 3 | 9 |
Seasonal Baseline — Use LAG(amount, 7) to compare the same weekday last week and avoid periodicity traps
Many metrics have weekly seasonality. Weekdays may be low while weekends are high, for example. Monitoring with a "previous-day comparison" turns normal weekly changes into false alerts every weekend and Monday. The fix is simple: compare with "the same weekday one week ago" instead of yesterday. LAG(amount, 7) retrieves the value seven rows earlier.
LAG(amount, 7) OVER (ORDER BY dt) -- Seven days earlier (same weekday last week) LAG(amount) OVER (ORDER BY dt) -- If omitted, the second argument defaults to 1 (yesterday) -- Change the offset to switch the comparison baseline between yesterday and the same weekday last week
LAG(col, n) returns the value n rows before the current row (n defaults to 1). With daily data ordered by date, seven rows back is the same weekday last week. The first seven days have no comparison value and become NULL, so exclude them from classification as no_baseline.From daily_traffic (14 days with high weekend values and weekly seasonality), compare each day's amount with the value seven days earlier and calculate the week-over-week change rate wow_pct =(amount − base_7d)/base_7d×100. Assign 'no_baseline' to the first seven days without a seven-day baseline, 'anomaly' when |wow_pct| exceeds 50%, and 'normal' otherwise. Return dt, amount, base_7d, wow_pct, status in ascending dt order. Round pct to one decimal place.
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 110 |
| 2024-01-03 | 120 |
| 2024-01-04 | 130 |
| 2024-01-05 | 140 |
| 2024-01-06 | 300 |
| 2024-01-07 | 310 |
| 2024-01-08 | 105 |
| 2024-01-09 | 115 |
| 2024-01-10 | 125 |
| 2024-01-11 | 135 |
| 2024-01-12 | 500 |
| 2024-01-13 | 305 |
| 2024-01-14 | 315 |
| dt | amount | base_7d | wow_pct | status |
|---|---|---|---|---|
| 2024-01-01 | 100 | NULL | NULL | no_baseline |
| 2024-01-02 | 110 | NULL | NULL | no_baseline |
| 2024-01-03 | 120 | NULL | NULL | no_baseline |
| 2024-01-04 | 130 | NULL | NULL | no_baseline |
| 2024-01-05 | 140 | NULL | NULL | no_baseline |
| 2024-01-06 | 300 | NULL | NULL | no_baseline |
| 2024-01-07 | 310 | NULL | NULL | no_baseline |
| 2024-01-08 | 105 | 100 | 5.0 | normal |
| 2024-01-09 | 115 | 110 | 4.5 | normal |
| 2024-01-10 | 125 | 120 | 4.2 | normal |
| 2024-01-11 | 135 | 130 | 3.8 | normal |
| 2024-01-12 | 500 | 140 | 257.1 | anomaly |
| 2024-01-13 | 305 | 300 | 1.7 | normal |
| 2024-01-14 | 315 | 310 | 1.6 | normal |
Composite-Signal Severity — Combine level and sudden-change signals with CASE to classify severity
Operational alerts need more than a binary "anomaly or not" flag. A common approach is to combine multiple signals and rank events by severity. This question combines two independent perspectives—level (is the value high?) and jump (did it change sharply from the previous day?)—to produce four levels. The keys are the order of CASE branches and using boolean values directly as conditions.
CASE WHEN lvl_hi AND jump_hi THEN 'CRITICAL' -- High level and sharp jump (classify first) WHEN lvl_hi THEN 'WARNING' -- High level but gradual WHEN jump_hi THEN 'WATCH' -- Sharp jump but low level ELSE 'OK' END
From metric_stream, calculate two signals: level signal lvl_hi =(amount > 200) and sudden-change signal jump_hi =(amount − previous amount ≥ 100). Combine them with CASE and assign severity as both true → 'CRITICAL' / level only → 'WARNING' / jump only → 'WATCH' / neither → 'OK'. Return dt, amount, delta, severity in ascending dt order, where delta = amount − previous amount.
| dt | amount |
|---|---|
| 2024-01-01 | 100 |
| 2024-01-02 | 105 |
| 2024-01-03 | 215 |
| 2024-01-04 | 220 |
| 2024-01-05 | 120 |
| 2024-01-06 | 235 |
| 2024-01-07 | 90 |
| 2024-01-08 | 195 |
| dt | amount | delta | severity |
|---|---|---|---|
| 2024-01-01 | 100 | NULL | OK |
| 2024-01-02 | 105 | +5 | OK |
| 2024-01-03 | 215 | +110 | CRITICAL |
| 2024-01-04 | 220 | +5 | WARNING |
| 2024-01-05 | 120 | -100 | OK |
| 2024-01-06 | 235 | +115 | CRITICAL |
| 2024-01-07 | 90 | -145 | OK |
| 2024-01-08 | 195 | +105 | WATCH |
Aggregate an Alert Summary — Use multiple FILTERs and GROUP BY ROLLUP to show categories and the grand total in one table
The advanced section closes by building an "alert summary" for operations. Extend the fundamentals with COUNT(*) FILTER columns for multiple severity levels, count each severity at once, and use GROUP BY ROLLUP to place category detail rows and one grand-total row in the same result. One query produces a dashboard-ready table.
COUNT(*) FILTER (WHERE amount >= 300) -- Critical count COUNT(*) FILTER (WHERE amount >= 200 AND amount < 300) -- Warning count GROUP BY ROLLUP (category) -- Category rows + one grand-total row (category=NULL)
GROUP BY ROLLUP (category) adds one grand-total row to the ordinary category groups. That row has category=NULL, so label it with COALESCE(category,'(ALL)') and control ordering with GROUPING(category).From event_log, define amount>=300 as critical and 200≤amount<300 as warning. Aggregate total events, critical count, warning count, and critical rate % by category. Include a grand-total row for all categories with ROLLUP, label that row (ALL), and return category, total_events, critical, warning, crit_pct with category rows ascending and the grand total last. Round pct to one decimal place.
| category | dt | amount |
|---|---|---|
| api | 2024-01-01 | 350 |
| api | 2024-01-02 | 120 |
| api | 2024-01-03 | 130 |
| api | 2024-01-04 | 95 |
| auth | 2024-01-01 | 260 |
| auth | 2024-01-02 | 280 |
| auth | 2024-01-03 | 240 |
| auth | 2024-01-04 | 90 |
| web | 2024-01-01 | 100 |
| web | 2024-01-02 | 250 |
| web | 2024-01-03 | 300 |
| web | 2024-01-04 | 320 |
| category | total_events | critical | warning | crit_pct |
|---|---|---|---|---|
| api | 4 | 1 | 0 | 25.0 |
| auth | 4 | 0 | 3 | 0.0 |
| web | 4 | 2 | 1 | 50.0 |
| (ALL) | 12 | 3 | 4 | 25.0 |