fix(alertmanager-plugin): bound and instrument operator suppression (BLO-24234) - #1349
Conversation
…BLO-24234) An operator closing an alert issue by hand muted its fingerprint forever, silently. `handleFiring`'s re-fire branch reopened a terminal issue only when `existing.resolvedAt` was set — i.e. only when the *plugin* had closed it on resolve. When a human closed it while the alert was still firing, `resolvedAt` stayed null and neither branch of the if/else ran: no reopen, and no description refresh either. The delivery still returned 200, the state row still updated, and `alertmanager.firing.deduped` still incremented — identical telemetry to a healthy re-fire against an open issue. `escalation.ts:377` independently skips terminal issues, so the ladder was silent too. An operator close was total, permanent silence on both paths. This is not hypothetical noise-suppression: an Alertmanager fingerprint is hash(sorted(labels)), so a provider-agnostic alert such as LLMProxyHighErrorRate reuses ONE fingerprint across every future root cause. One operator closing a noisy issue mutes an unrelated outage months later. The suppression itself is deliberate (README, and a test pinned it) and is kept. What changes is that it is now bounded and observable: - `operatorSuppressionHours` (default 24, `0` = old unbounded behaviour). Past the window a still-firing alert reopens the issue with a comment explaining why the close did not stick, and re-arms the escalation ladder — which has been frozen for the whole window. - The window is anchored on the first re-fire observed against the closed issue (the plugin never sees the close) and is NOT refreshed by later re-fires, otherwise it would slide forever and never expire. - Every re-fire now emits a metric naming what it did: `firing.suppressed`, `firing.suppression_expired`, `firing.reopened`, `firing.issue_missing`. `firing.deduped` is unchanged so existing dashboards keep working. Also fixed while here: a re-fire whose issue could not be read fell through both branches in silence; it now warns and emits `firing.issue_missing`. The decision is extracted to `decideRefire()` so the branches are enumerable in one place. State is only written for a decision that was actually applied — a failed issues.update must not bank a suppression anchor for a status nobody observed. `recoverStateFromIssue`'s refusal to adopt a terminal issue is left alone and documented: after a state loss the plugin cannot tell whose close it was, and a visible duplicate is a safer failure than an inherited mute. Tests: 11 new cases covering each decision point, the anchor lifecycle, ladder re-arm, `operatorSuppressionHours: 0`, unparseable anchors, and RPC failure. All 11 verified failing against the pre-fix handler. Suite 184/184, tsc clean. Co-Authored-By: Claude <noreply@anthropic.com>
1 similar comment
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
The handler tests drive these branches through a whole webhook delivery, which is the right level for asserting side effects (metrics, comments, state writes). These assert the decision itself, so the README's decision table has a cheap direct counterpart and a future change to the branch ORDER fails here with an obvious diff rather than as a surprising side effect three layers up. Covers the boundary (>= vs >, so a re-fire landing exactly on the tick is not suppressed for another whole window), custom windows, `operatorSuppressionHours: 0`, and the precedence of a plugin-resolved re-open over a stale suppression anchor. Also pins that a negative/NaN/Infinity setting falls back to the default rather than reading as 0 — otherwise a config typo silently becomes an unpageable alert, which is the exact failure class this series is fixing. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please review at head Review focus:
Note: the 5 red checks on this head were a single ARC runner eviction at 21:57:04Z during |
|
@ally please review at head 987994b — alertmanager-plugin: bound and instrument operator suppression. Focus on whether the bound can drop a suppression that is still needed. Context: the original review request on this PR was lost during the codex provider outage (BLO-27123) — codex |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 987994b
Critical Issues (0)
Important Issues (0)
Suggestions (1)
- [native-codex]
packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:564— Consider adding an explicit upper bound or checking the multiplied millisecond value for finiteness when acceptingoperatorSuppressionHours; an extremely large finite JSON number can overflow the multiplication and behave like an unbounded mute. This is defensive hardening rather than a blocker for normal configuration values.
Strengths
- The pure
decideRefire()table makes terminal-status, plugin-resolved, bounded suppression, expiry, invalid-anchor, and missing-issue behavior explicit and testable. - The state update is guarded so failed issue RPCs do not incorrectly advance or clear the suppression anchor.
- Expiry re-opens the issue, emits distinct telemetry, explains the reason to the operator, and re-arms escalation.
Recommended Action
- Consider the defensive overflow guard in a follow-up.
- Merge after the existing green checks remain green.
…w always ends (BLO-24234) Follow-up to #1349, from Ally's sole review suggestion on it. `operatorSuppressionMs()` validates the configured hours with `Number.isFinite` and `>= 0`, then multiplies by 3.6e6 to get the window. The guard is on the input, not the product, so two finite configs still produce a window that never expires: - anything above ~5e301 overflows the multiplication to `Infinity`, and `nowMs - anchorMs >= Infinity` is never true; - a merely large finite value needs no overflow at all — 1e15 hours is ~1e11 years. Either one restores the permanent, silent mute that #1349 exists to remove, reachable through a config typo rather than a code path. That makes this worth closing even though normal values are unaffected: the whole point of the parent change is that a muted fingerprint cannot outlive an on-call shift. Clamp to MAX_OPERATOR_SUPPRESSION_HOURS (720h / 30 days) before the conversion. A ceiling rather than a finiteness check on the product, because a finiteness check would still pass the 1e15 case. `Math.min(0, ceiling)` is 0, so the documented `operatorSuppressionHours: 0` opt-in to indefinite suppression is untouched — clamping takes nothing away that an operator cannot still ask for deliberately. The clamped value flows through operatorSuppressionHoursLabel() and suppressionExpiryLabel(), so a clamped config reports the window it actually got in both the re-open comment and the suppression logs, rather than echoing the unreachable number the operator typed. Tests: 4 added to the decideRefire block. Verified they discriminate — with the clamp reverted and the tests kept, 3 of the 4 fail (overflow, geological, and the ceiling-boundary case); the fourth is a regression guard asserting `0` still means indefinite, which passes both ways by design. npx tsc --noEmit -p tsconfig.json # clean npx vitest run # 200/200 pass (was 196; +4) Co-Authored-By: Claude <noreply@anthropic.com>
…w always ends (BLO-24234) Follow-up to #1349, from Ally's sole review suggestion on it. `operatorSuppressionMs()` validates the configured hours with `Number.isFinite` and `>= 0`, then multiplies by 3.6e6 to get the window. The guard is on the input, not the product, so two finite configs still produce a window that never expires: - anything above ~5e301 overflows the multiplication to `Infinity`, and `nowMs - anchorMs >= Infinity` is never true; - a merely large finite value needs no overflow at all — 1e15 hours is ~1e11 years. Either one restores the permanent, silent mute that #1349 exists to remove, reachable through a config typo rather than a code path. That makes this worth closing even though normal values are unaffected: the whole point of the parent change is that a muted fingerprint cannot outlive an on-call shift. Clamp to MAX_OPERATOR_SUPPRESSION_HOURS (720h / 30 days) before the conversion. A ceiling rather than a finiteness check on the product, because a finiteness check would still pass the 1e15 case. `Math.min(0, ceiling)` is 0, so the documented `operatorSuppressionHours: 0` opt-in to indefinite suppression is untouched — clamping takes nothing away that an operator cannot still ask for deliberately. The clamped value flows through operatorSuppressionHoursLabel() and suppressionExpiryLabel(), so a clamped config reports the window it actually got in both the re-open comment and the suppression logs, rather than echoing the unreachable number the operator typed. Tests: 4 added to the decideRefire block. Verified they discriminate — with the clamp reverted and the tests kept, 3 of the 4 fail (overflow, geological, and the ceiling-boundary case); the fourth is a regression guard asserting `0` still means indefinite, which passes both ways by design. npx tsc --noEmit -p tsconfig.json # clean npx vitest run # 200/200 pass (was 196; +4) Co-Authored-By: Claude <noreply@anthropic.com>
Thinking Path
Linked Issues or Issue Description
LLMProxyHighErrorRaterow whose 50+ comment history is the evidence trailTwo corrections to the ticket's framing, from reading the source:
resolvedAtset) → re-opens. Operator-closed (resolvedAtnull) → silent. Both prior observations are consistent with that one rule, so there is no race to hunt.Related open Alertmanager PRs, searched and confirmed non-overlapping: #1256 / #1114 (aggregate lifecycle), #923 (aggregate-safe intake), #1277 (severity=none routing), #909 (per-company state scope). None touch the re-fire/re-open decision.
What Changed
webhook-handler.ts— extracteddecideRefire(), which returns one ofrefresh/reopen/suppressed/issue_missing, so every re-fire branch is enumerable and testable in one place.resolvedAt(operator-closed) suppresses re-opens foroperatorSuppressionHours, then re-opens with a comment explaining why the close did not stick, and re-arms the escalation ladder that had been frozen for the whole window.operatorSuppressionHours(types.ts), defaultDEFAULT_OPERATOR_SUPPRESSION_HOURS = 24(constants.ts).0restores the previous unbounded behaviour.operatorSuppressedAtonAlertStateRecord, optional for backward compatibility with rows written before this change.firing.suppressed,firing.suppression_expired,firing.issue_missing.firing.dedupedandfiring.reopenedare unchanged, so existing dashboards keep working.firing.issue_missing.issues.updatecannot bank a suppression anchor for a status nobody observed.recoverStateFromIssueasymmetry.worker.test.ts.resolvedAtfiring.dedupedtodofiring.reopenedfiring.suppressedfiring.suppression_expiredfiring.issue_missingVerification
webhook-handler.ts, kept the tests, and confirmed 9 failed at that point; 2 further regression guards were added afterwards. They are exercising the change, not passing vacuously.does not re-open an operator-cancelled issue on re-firestill passes unmodified, which is the check that the deliberate suppression contract is preserved inside the window.firing.suppressedcounter is the signal to watch in production — if the defect is as described, it should start incrementing onLLMProxyHighErrorRateimmediately.Risks
Low-to-moderate; the behavioural shift is intentional and opt-out-able.
operatorSuppressionHours: 0restores the old behaviour per-instance if any deployment wants it.operatorSuppressedAtis optional, so existing state rows deserialize unchanged and are treated as "suppression starts on next observation" rather than "already expired". No migration, no backfill; the field is additive on a JSON state row.existing.resolvedAtrather than narrowed to the re-open branch — an operator can re-open the issue by hand between resolve and re-fire, making that a plainrefreshwhilehandleResolvedhas already nullednextEscalationAt; gating on the re-open would silently disarm escalation for that case. There is a dedicated regression test (restarts the ladder on resolve→re-fire even when the issue is already open), because I introduced exactly that bug mid-change and caught it on self-review.recoverStateFromIssuestill refuses to adopt a terminal issue, so a lost state row plus a closed issue files a fresh issue rather than reviving the old one. After a state loss the plugin cannot tell whose close it was, and a visible duplicate is a safer failure for a paging system than an inherited mute. Documented in-code and in the README so it is not "unified" into silence later.Model Used
Claude Opus 5 (
claude-opus-5[1m], 1M context), extended thinking, running as the Paperclip CTO agent via theclaude_k8sadapter with tool use (repo checkout, vitest/tsc execution, GitHub + Paperclip MCP).Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template