feat(alerts): a dead man's switch on the notification path - #119
Merged
Conversation
AlertmanagerNotificationsFailing catches delivery ERRORS — a refused connection,
a 5xx. It cannot catch a webhook URL that is well-formed, reachable, and pointed
at nothing: a 200 into a deleted ntfy topic is a successful notification by every
measure Alertmanager has.
That is not hypothetical here. The webhook was the ntfy.example.invalid
placeholder for the entire life of the stack and nothing noticed, because the
only symptom is that alerts stop arriving — which is also what a healthy week
looks like.
This repository already applies the reasoning elsewhere. security.rules.yaml has
FirewallLogsStopped because a firewall passing traffic for the whole house is
never silent for thirty minutes, and it spends ten lines explaining why there is
deliberately no SuricataStopped rule: absence of alerts is indistinguishable from
absence of the service, and detecting that needs a heartbeat rather than a
threshold. The notification path was the one place the argument had never been
turned on itself.
So: one rule, `vector(1)`, firing unconditionally and forever. Its firing carries
no information. Its ABSENCE is the entire signal, and one `continue: true` — the
only one in the routing tree — makes that absence observable two ways:
heartbeat external cron-monitor ping 5m Prometheus stopped evaluating,
Alertmanager died, no egress
default the real alert channel 24h the alert channel itself is a
200 into nothing
Neither half substitutes for the other. The heartbeat proves delivery to a
DIFFERENT url than real alerts use, so it cannot see a deleted topic. The daily
notification travels the identical url your warnings travel, but nothing
machine-checks its absence — you do. The watcher lives off this host by
necessity: one here fails at the same moment as the thing it watches.
`severity: none` on the rule is load-bearing, not a placeholder. It keeps the
alert out of the bare severity routes, where `info` would send it to the "null"
receiver; and out of the second inhibit rule, which matches
severity =~ "warning|critical" with equal: ["instance"] — Alertmanager treats
absent == absent as equal, so a Watchdog carrying a warning severity and no
instance label would be silenced every time InstanceDown fired. A dead man's
switch that goes quiet exactly when a host goes down is worse than none.
That is what the unit test asserts. It has no paired quiet case, deliberately —
`vector(1)` is unconditional, so a silent case would be asserting a bug — and it
checks the label set instead, which is the part that can break without promtool
or the Prometheus status page noticing. Confirmed by mutation: changing severity
to `warning` fails the test. The routing assertions gained a row for the same
reason, and it is the only one expecting two receivers.
Rule counts updated where they are live claims: 34 → 35 metric, 47 → 48 total.
docs/roadmap.md:182 is left alone; it records what was built at the time, and
#72 owns that drift.
Closes #67
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Owner
Author
|
Merged, not deployed — |
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
One rule —
Watchdog,expr: vector(1)— that fires unconditionally andforever, plus a
heartbeatreceiver and the routing to make its absenceobservable in two independent ways.
Stacked on #118. Base is
feat/three-notification-channels, because bothtouch
alertmanager.yaml,render-config.sh,verify-key-backup.shand thesecrets example. GitHub will retarget this to
mainwhen #118 merges.One new SOPS key:
ALERTMANAGER_HEARTBEAT_URL— a cron-monitor ping URL,not an ntfy topic. See "Before deploying".
Why
Closes #67.
AlertmanagerNotificationsFailingcatches delivery errors — a refusedconnection, a 5xx. It cannot catch a webhook URL that is well-formed, reachable,
and pointed at nothing: a 200 into a deleted ntfy topic is a successful
notification by every measure Alertmanager has.
Not hypothetical. The webhook was the
ntfy.example.invalidplaceholder for theentire life of the stack and nothing noticed, because the only symptom is that
alerts stop arriving — which is also what a healthy week looks like.
The repository already applies this reasoning elsewhere.
security.rules.yamlhasFirewallLogsStoppedbecause a firewall passing traffic for the whole house isnever silent for thirty minutes, and it spends ten lines explaining why there is
deliberately no
SuricataStoppedrule: "absence of alerts is indistinguishablefrom absence of the service", and detecting that needs a heartbeat rather than a
threshold. The notification path was the one place the argument had never been
turned on itself. That comment now points at the worked example.
How it works
Watchdog's firing carries no information. Its absence is the entire signal,and one
continue: true— the only one in the routing tree — sends one rule totwo places on two cadences:
heartbeatdefaultNeither half substitutes for the other. The heartbeat proves delivery to a
different URL than real alerts use, so it cannot see a deleted topic. The daily
notification travels the identical URL your warnings travel, but nothing
machine-checks its absence — you do. The watcher lives off this host by
necessity: one here fails at the same moment as the thing it watches.
severity: noneis load-bearingNot a placeholder, and the rule comment says so at length:
severityroutes. Withinfoitwould be swallowed by the
"null"receiver and the switch would be silentlydisarmed.
inhibit_rules. The second matchestarget_matchers: [severity =~ "warning|critical"]withequal: ["instance"]— and Alertmanager treats absent == absent as equal, so a
Watchdogcarrying a warning severity and no
instancelabel would be silenced everysingle time
InstanceDownfired.A dead man's switch that goes quiet exactly when a host goes down is worse than
none: it reports the path healthy right up to the moment it stops, then looks
like the host's fault.
Blast radius
secrets/*.sops.yamlOne new Prometheus rule (one extra series from
vector(1); cardinality impact isnil), one new Alertmanager receiver, one new SOPS key. The external watcher is
configured outside this repo.
Verification
make validate— all pass, one skip (gitleaksnot installed locally).The test has no paired quiet case, deliberately
containers.test.yamlpairs every firing case with a silent one, because a testthat only ever expects silence would have passed against the unfireable rule in
#63.
Watchdogis the mirror image:vector(1)is unconditional by design, sothere is no input that should silence it and a quiet case would be asserting a
bug. The header comment says this rather than leaving it to be wondered about.
What is under test instead is the label set, which is the part that can break
silently. Confirmed by mutation — changing
severity: nonetoseverity: warning:The routing assertions gained a row for the same reason, and it is the only one
expecting two receivers:
That row is also what would catch a
severity: infoslip — it would resolve tonull, and the assertion fails.render-config.sh
Exercised on the host with throwaway URLs supplied via the environment, so
nothing went near the encrypted file:
Rule counts confirmed by counting, not by assuming: 35 metric, 13 Loki.
make validatepassesverify-the-alert-path.md, a### The dead man's switchsection indocs/observability.md, and thelive rule counts moved 34 → 35 / 47 → 48 in five places
docs/roadmap.md:182is deliberately left at "34 rules" — it records what wasbuilt at the time rather than a current count, and #72 owns that drift.
Before deploying
make secrets-edit # set ALERTMANAGER_HEARTBEAT_URL make upThe ping URL comes from something that lives off this host — healthchecks.io
(free tier is enough for one check), Cronitor, or an Uptime Kuma push monitor on
another machine. Set the check's period to 5m and grace to 15m; the runbook
explains why those are tied to
repeat_intervaland what breaks if you move onewithout the other.
Point that check's own notification somewhere that is not this stack's
webhook. If both land on the same ntfy topic, a deleted topic takes out the alert
and the warning about the alert together.
Then, once, actually watch it trip — a dead man's switch nobody has seen go red
is indistinguishable from one that does not work:
🤖 Generated with Claude Code