|
| 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