Skip to content

Commit c8d06c0

Browse files
committed
plan: trend-based alerting noise damping (stages 2-4)
The exploration and staging plan for reducing close-reopen alert noise beyond the static linger: rolling transition aggregates recorded at filing time, adaptive linger and open-grace windows derived from them, Nagios-style flapping state, and operator-in-the-loop pattern suggestions. Includes the off-the-shelf survey (pipeline services vs embedded analysis libraries) and the augurs/changepoint maintenance check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AEZZ9HMkKAFqrJzTg3FrE7
1 parent 3efcbbb commit c8d06c0

1 file changed

Lines changed: 279 additions & 0 deletions

File tree

docs/plans/alerting-hysteresis.md

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
# Close–reopen noise: linger and trend-based damping
2+
3+
Exploration of how to reduce close-then-immediately-reopen alert noise, on
4+
top of the incident-pipeline rework (CHK/INC/STA). Stage 1 (the static
5+
linger) is implemented in PR #371; the staging section at the end tracks
6+
the rest.
7+
8+
## The problem: grace is one-sided
9+
10+
The open side is handled. An incident's Slack open sits in the outbox for
11+
the group's `slack_open_delay` (default 3 minutes); an incident that closes
12+
inside that window cancels the open and skips the resolve, so a briefly-red
13+
check never reaches Slack.
14+
15+
The close side has nothing. The moment the last effective failure leaves —
16+
one green report is enough — the incident closes and the resolve ships. When
17+
the check goes red again two minutes later, that is a *new* incident row:
18+
fresh grace, fresh open message, fresh escalation budget.
19+
20+
So a red check that blips green costs, per blip:
21+
22+
- a **resolve** message ("all clear") that was wrong within minutes;
23+
- a new **open** message one grace-period later;
24+
- for an escalating check, potentially a fresh **Critical** open, since
25+
`escalated_at`'s once-per-incident cap resets with each new row;
26+
- a fragmented record: one span of trouble becomes N incident rows, which
27+
distorts incident counting, duration stats, and MCP reporting.
28+
29+
There is also an inverse failure mode hiding in the current design: a check
30+
that flaps red/green *faster* than the grace period never accumulates a
31+
continuous red span longer than grace, so each of its incident rows closes
32+
within its own window and is cancelled. The target can be in trouble for
33+
hours and Slack never hears anything at all. Both failure modes have the
34+
same root cause — incident identity is tied to continuous redness rather
35+
than to the span of trouble.
36+
37+
## The simple fix: linger (close-side grace)
38+
39+
Make the close symmetric with the open. When the last effective failure
40+
leaves an open incident, the incident does not close; it starts **lingering**
41+
(stamp `closing_at`). Two ways out:
42+
43+
- An effective failure joins (or rejoins) the target while lingering →
44+
clear `closing_at`; the *same* incident continues. No resolve was sent,
45+
no new open is enqueued, `escalated_at` and the pending-open row carry
46+
over. Issue join/leave timeline entries record the blip.
47+
- The linger window elapses with no failure → finalize: set `closed_at`
48+
**backdated to `closing_at`** (the truthful end of trouble), then run
49+
exactly today's close path — cancel a still-pending open, or ship the
50+
resolve.
51+
52+
The finalizer is a minute-cadence DB-only sweep, which is exactly what the
53+
monitor pod's loop is for (`sweep_staleness` et al.); a
54+
`sweep_lingering_incidents` rides the same tick.
55+
56+
### Interaction with the open-side grace
57+
58+
The subtle part. Today the close cancels a pending open immediately; with
59+
linger, cancellation moves to finalize. That changes what the open delay
60+
measures, deliberately:
61+
62+
- The pending open row keeps its original `deliver_after`, counting from
63+
when the incident first opened.
64+
- The drainer must **not ship an open while its incident is lingering**
65+
otherwise a single 30-second blip would notify at the 3-minute mark
66+
purely because the linger held the incident open past its grace. A
67+
drainer-side gate (skip `incident_open` rows whose incident has
68+
`closing_at` set) is stateless and enough.
69+
- If the incident finalizes closed before the open ever shipped → cancel,
70+
as today. A genuine one-off blip stays silent.
71+
- If a failure rejoins and the row is past `deliver_after` → it ships on
72+
the next drain tick.
73+
74+
Net effect: an incident publishes once it is **older than grace and
75+
currently red**. That fixes the fast-flapper hole as a side effect — a
76+
check flapping every couple of minutes keeps one incident row alive
77+
(rejoining within linger), so three minutes after the trouble started the
78+
open ships, once. Today that scenario ships nothing forever.
79+
80+
Escalating checks are unaffected on the open side (they bypass grace and
81+
linger alike: `deliver_after = now`); on the close side they benefit the
82+
most, since one surviving row means at most one Critical re-notify per span
83+
of trouble instead of one per blip.
84+
85+
### Cost and knob
86+
87+
The only behavioural cost is that resolves arrive up to one linger window
88+
late. That's cheap against a resolve+reopen pair, but it argues for a
89+
modest default, not a huge one.
90+
91+
Knob: `slack_close_delay INTERVAL` on `server_groups` next to
92+
`slack_open_delay` (plus a matching const for the global target). Default
93+
somewhat larger than the open delay — 5 minutes — because green→red→green
94+
oscillation is typically slower than the report cadence. Name it in UI
95+
terms as "linger" or "hold-open", not "close delay" (nothing about the
96+
close is being hidden; the incident is genuinely still suspect).
97+
98+
Spec impact (INC): "The incident closes when its last effective failure
99+
leaves" becomes "…when its last effective failure has been gone for the
100+
target's linger window; a failure returning within the window continues the
101+
same incident; the close is recorded as of when the last failure left."
102+
103+
## Trend-based damping: beyond one hard threshold
104+
105+
A single fleet-wide linger constant treats a check that has been green for
106+
a year the same as one that flaps every lunchtime. The history to do better
107+
already exists. The question is what to compute, at what scope, and how
108+
much to let it act autonomously.
109+
110+
### Signal
111+
112+
Use **observed** results, not effective ones, so the statistics are
113+
untouched by policy edits and by the damping machinery itself (no feedback
114+
loop).
115+
116+
Raw history exists in `statuses` (complete, per-source, timestamped), but
117+
it only covers device-reported checks — canopy's own filings (staleness,
118+
backup, key expiry…) leave no history beyond the current issue stamps — and
119+
mining per-check series out of status JSONB is a scan-heavy job. Since
120+
every producer already funnels through one filing path that sees every
121+
transition (`stamp_check_state`), the cheap and uniform option is to
122+
maintain **rolling aggregates at filing time**, updated in place. We just
123+
deleted the `events` table for being an unbounded append-only log; the
124+
replacement must be fixed-size per key, not a new log. Per
125+
(server/group/global target, source, check):
126+
127+
- **flip stats**: a small ring of the last K degraded↔healthy transition
128+
timestamps (K fixed, ~16–32). Everything below derives from it: flip
129+
rate over 24h/7d, typical degraded-run length, typical healthy-gap
130+
length between runs.
131+
- **hour-of-week duty cycle**: 168 buckets of EWMA "observed degraded",
132+
updated on each filing. Fixed size, captures the load-dependent shape
133+
(red business hours, green nights) directly. Timezone: bucket in UTC —
134+
the pattern is just as periodic in UTC and saves per-server timezone
135+
bookkeeping; DST smears one hour twice a year, which the EWMA absorbs.
136+
137+
Group- and fleet-scope views aggregate the server rows for the same
138+
(source, check) at read time — fleet answers "is this check noisy
139+
everywhere or only here", mirroring the fleet → group → server layering
140+
policy already has.
141+
142+
### Uses, in increasing order of ambition
143+
144+
1. **Adaptive linger.** When the last failure leaves, size the linger from
145+
the leaving check's own history: `clamp(default, k × p90(healthy-gap
146+
within recent degraded episodes), cap)`. The day-red/night-green check
147+
earns a linger that bridges its usual green gaps; the green-for-a-year
148+
check keeps the minimum — its recovery is believable immediately. The
149+
cap matters (an unbounded linger delays legitimate resolves by hours);
150+
a scoped-policy-style override can raise it per target where an
151+
operator wants the nightly green bridged entirely.
152+
153+
2. **Adaptive open grace.** The mirror image. A check red for the first
154+
time in 90 days fleet-wide is high-information — shrink the wait; a
155+
check that flaps weekly on this server — stretch it. Bounded both ways;
156+
`escalates` remains absolute and bypasses everything.
157+
158+
3. **Flapping state.** When the flip rate crosses a threshold
159+
(Nagios-style flap detection), mark the (target, source, check) as
160+
flapping: notify **once** ("`pg/connections` on X is flapping: 14
161+
state changes in 6h"), suppress the per-flip churn while it lasts, and
162+
notify once when it stabilises in either direction. This is the honest
163+
answer for the pathological case, and it is also the safety valve that
164+
keeps adaptive damping from teaching itself to never alert: heavy
165+
flapping gets *more* visible, not silently absorbed.
166+
167+
4. **Pattern surfacing — suggest, don't act.** Classify profiles from the
168+
duty cycle (e.g. degradation concentrated in business hours =
169+
load-dependent) and turn the big interventions into *suggestions* the
170+
operator confirms: a scoped warning-ceiling for that (server, source,
171+
check), a longer scoped linger, or a silence. Policy is the operator's
172+
word in this model; statistics should inform it, not overwrite it. The
173+
UI hook is natural — the check attention page and the incident
174+
timeline show the profile and the one-click suggestion.
175+
176+
### Design tensions to respect
177+
178+
- **Explainability.** Every damping decision must leave a visible trace:
179+
"resolve held 45 min: green gaps during this check's episodes are
180+
typically ~30 min", on the incident timeline and over MCP. An operator
181+
who can't see why nothing alerted stops trusting the pipeline.
182+
- **Normalizing deviance.** A check that is red every business day *is* a
183+
problem (capacity), and damping must not bury it. Mitigations: the
184+
flapping-state notification, hard caps on adaptive windows,
185+
suggestions-over-automation for anything larger, and published-incident
186+
reporting continuing to count the (now well-formed) spans.
187+
- **Cold start.** No history → static defaults. Aggregates warm up
188+
within days.
189+
- **Cheapness.** Everything stays minute-cadence and DB-only: in-place
190+
aggregate updates on the filing path, no history scans in the hot loop.
191+
192+
## Off-the-shelf options considered
193+
194+
Surveyed (July 2026) for something to host alongside canopy — or embed —
195+
instead of building the analysis/damping ourselves. Two distinct layers to
196+
buy: the *notification pipeline* and the *analysis*.
197+
198+
### Pipeline services (forward alerts to them, they decide what notifies)
199+
200+
- **Prometheus Alertmanager** (Go, tiny, battle-tested): grouping,
201+
inhibition, silences, throttling. Its `group_interval` batching absorbs
202+
some flapping, but it has explicitly declined flap detection for years
203+
(prometheus/alertmanager#204), has no per-alert adaptive behaviour, and
204+
no history-based anything.
205+
- **Alerta** (Python): the closest conceptual match — ships an
206+
`is_flapping(window, count)` plugin utility and a transient-alert plugin
207+
that grades flapping alerts down so notifiers skip them. But it is a
208+
whole parallel alert store + UI + API.
209+
- **Keep** (Python/TypeScript, multi-service deployment): dedup,
210+
correlation, enrichment platform; the AI correlation sits behind paid
211+
tiers, and flap damping specifically isn't a feature.
212+
- **Grafana OnCall OSS**: archived March 2026, read-only — off the table.
213+
214+
All of these sit between "alert fired" and "notification sent". Plugging
215+
one in means forwarding incident opens/closes as alerts and letting it
216+
decide what ships — which outsources notification decisioning but does
217+
nothing about the *record*: incidents would still fragment in our database
218+
on every blip, `published`/escalation state would still reset per row, and
219+
the MCP/UI/timeline surfaces would still show N incidents for one span of
220+
trouble. The noise problem lives in the incident model, not just in Slack
221+
delivery, so the fix has to live there too (which the linger does). Running
222+
a second alert brain alongside canopy's own — with its own store, silences,
223+
and UI — costs more operational surface than the ~200 lines of damping
224+
logic it would replace, and the bespoke Slack-workflow contract would need
225+
adapting either way.
226+
227+
### Analysis libraries (embed, keep the pipeline ours)
228+
229+
- **augurs** (Rust, MIT/Apache, under the Grafana org): seasonality
230+
detection, MSTL decomposition, changepoint detection (Bayesian online
231+
changepoint via the `changepoint` crate), outlier detection (MAD/DBSCAN),
232+
forecasting (ETS/Prophet). In-process, no service to run.
233+
Maintenance check (July 2026): alive but quiet — v0.10.2 released
234+
February 2026 (266 releases to date), commits through July 2026 though
235+
mostly bot dependency bumps, one primary human maintainer (a Grafana
236+
engineer; the README says explicitly it is *not* an official Grafana
237+
project and still pre-1.0), ~575 stars, a dozen open issues with slow
238+
responses. Fine as a leaf dependency, not as architecture.
239+
- **changepoint** (Rust, MIT, promised-ai): BOCPD directly, if only that
240+
is wanted; small and stable, last release October 2025.
241+
242+
Vendor risk is low either way: the pieces damping actually needs —
243+
periodogram seasonality, BOCPD, MAD — are small, textbook algorithms on
244+
tiny inputs (per-check aggregates, not raw series), so if upstream stalls
245+
the crates can be pinned or the ~few hundred relevant lines vendored. The
246+
heavyweight parts of augurs (Prophet, DTW, clustering) would go unused.
247+
248+
This is the sweet spot for stages 3–4: canopy keeps every decision and all
249+
explainability, and buys the statistics — "is this check's degradation
250+
periodic?" (seasonality over the duty-cycle aggregate) and "did its
251+
behaviour just change?" (changepoint over the flip rate, a natural
252+
alert-now / suppress-now signal). The flap-detection algorithm itself
253+
(Nagios/Icinga's weighted state-change ratio) is ~50 lines and not worth a
254+
dependency.
255+
256+
**Conclusion**: nothing plugs in at the incident layer without ceding the
257+
incident model; adopt augurs as an embedded dependency when stage 3 lands,
258+
and keep the pipeline ours.
259+
260+
## Staging
261+
262+
1. **Static linger.** ✅ Implemented (PR #371). Small, symmetric with the existing
263+
grace, immediate relief, and fixes the fast-flapper silence hole. Spec
264+
change to INC, `closing_at` on incidents, drainer gate, monitor-pod
265+
sweep, group knob (`slack_close_delay`, default 5 minutes) + UI field.
266+
Only report-driven recoveries linger: operator suppression (resolve,
267+
snooze, silence, monitoring-off) closes immediately — an explicit
268+
action isn't a flap.
269+
2. **Transition aggregates.** Recorded at filing time, invisible to
270+
behaviour; surfaced read-only as a stability indicator on the check
271+
attention page and over MCP. Independently useful for triage even if
272+
nothing below ever ships.
273+
3. **Adaptive linger, then adaptive open grace.** Derived from the
274+
aggregates, bounded, each decision traced in the UI.
275+
4. **Flapping state and pattern suggestions.** The operator-in-the-loop
276+
layer.
277+
278+
1 and 2 are independent of each other and both prerequisites for 3; each
279+
stage is useful on its own if the later ones never happen.

0 commit comments

Comments
 (0)