monitoring: reduce Slack alert noise (alert-on-change + daily digest)
Reviewed the last 24h of Slack alerts after the midday node-pressure blip: the volume came far less from the outage than from (a) alerts re-pinging every few hours while nothing changed and (b) a pod cascade that fired uninhibited. This hardens the alerting *system* so recurrences are quiet, rather than just clearing today's broken services. Changes (all in the monitoring module): * Alert-on-change routing. warning/info repeat_interval -> 8760h (notify once, then only on a membership change or resolve); critical 1h -> 6h (a slow nag, not an hourly drip). send_resolved stays on. The bulk of the 24h volume was these re-pings (RpiSofiaUndervoltage alone fired continuously for ~24h, re-notifying every 4h). * Daily digest CronJob (alert_digest.tf + alert_digest.py) -> #alerts at 08:00 Europe/London: the full current board grouped by severity + what resolved in the last 24h. This is the standing-state safety net for the alert-on-change model. Stock python:3.12-alpine, pure-stdlib script (no pip/apk at runtime -> none of the per-run disk-write footprint that disabled status-page-pusher). Reuses the existing Alertmanager Slack webhook via a namespaced Secret; reads Alertmanager v2 + Prometheus. * Cascade inhibition. NodeConditionBad/NodeDiskPressure now suppress the downstream pod-churn alerts (PodCrashLooping, PodImagePullBackOff, PodsStuckContainerCreating, ScrapeTargetDown, *ReplicasMismatch, ...). The midday DiskPressure event on 4 nodes fired 25 PodCrashLooping + 14 PodImagePullBackOff uninhibited because only NodeDown was a source. * T3 probe de-duplication. T3ProbeLegDown now inhibits T3ProbeDropBurst for the same leg — two alerts described one condition and were the #1 noise source (~3,400 alert-minutes over 24h). * ScrapeTargetDown false positives. Scrape only Ready endpoints, so completed CronJob pods that linger in EndpointSlices as NotReady addresses stop firing phantom "down" alerts (tts/tripit/beads). A Ready pod with a genuinely broken metrics endpoint still fires. * for: 0m -> 5m on the flappy backup-status flags (LVM/Weekly/Offsite/ NfsMirror/Vzdump *Failing) and DNS spike detectors, so a single transient Pushgateway/scrape blip no longer fires-and-resolves. * Added an Alertmanager scrape target: it carried no prometheus.io/scrape annotation, so notification volume was unmeasurable — now we can verify this change worked (alertmanager_notifications_total et al.). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
87a8a393fe
commit
97dcf49b8e
4 changed files with 434 additions and 12 deletions
230
stacks/monitoring/modules/monitoring/alert_digest.py
Normal file
230
stacks/monitoring/modules/monitoring/alert_digest.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Daily alert digest -> Slack.
|
||||
|
||||
Posts a once-a-day "state of the lab" summary to the #alerts Slack channel:
|
||||
the full current board of firing alerts grouped by severity, plus a one-line
|
||||
list of what fired-and-cleared in the last 24h.
|
||||
|
||||
This is the safety net for the "alert on change" routing model (warnings/info
|
||||
no longer re-notify while firing; criticals re-ping slowly). The digest is the
|
||||
recurring reminder of everything still firing, reviewed once each morning.
|
||||
|
||||
Pure stdlib (urllib + json) on purpose: the CronJob runs stock python:alpine
|
||||
with NO pip/apk install at runtime, so it has none of the per-run disk-write
|
||||
footprint that got status-page-pusher disabled (infra memory id=559).
|
||||
|
||||
Sources:
|
||||
* Current board -> Alertmanager v2 (/api/v2/alerts): active, not silenced,
|
||||
not inhibited == exactly what a human would otherwise be paged about, with
|
||||
the human-readable `summary` annotation. Falls back to Prometheus ALERTS
|
||||
if Alertmanager is unreachable.
|
||||
* Resolved-in-24h -> Prometheus (alertnames seen firing in the last 24h that
|
||||
are not firing now). Best-effort; skipped silently if Prometheus errors.
|
||||
|
||||
Env (all have in-cluster defaults):
|
||||
ALERTMANAGER_URL default http://prometheus-alertmanager.monitoring.svc.cluster.local:9093
|
||||
PROMETHEUS_URL default http://prometheus-server.monitoring.svc.cluster.local:80
|
||||
SLACK_WEBHOOK_URL Slack incoming-webhook URL. If empty (or DRY_RUN set),
|
||||
the payload is printed to stdout instead of posted.
|
||||
SLACK_CHANNEL default "#alerts"
|
||||
DRY_RUN if set (any value), print instead of posting.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ALERTMANAGER_URL = os.environ.get(
|
||||
"ALERTMANAGER_URL",
|
||||
"http://prometheus-alertmanager.monitoring.svc.cluster.local:9093",
|
||||
).rstrip("/")
|
||||
PROMETHEUS_URL = os.environ.get(
|
||||
"PROMETHEUS_URL",
|
||||
"http://prometheus-server.monitoring.svc.cluster.local:80",
|
||||
).rstrip("/")
|
||||
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "").strip()
|
||||
SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL", "#alerts")
|
||||
DRY_RUN = bool(os.environ.get("DRY_RUN", "")) or not SLACK_WEBHOOK_URL
|
||||
|
||||
SEV_ORDER = ["critical", "warning", "info"]
|
||||
SEV_EMOJI = {"critical": ":red_circle:", "warning": ":large_yellow_circle:", "info": ":large_blue_circle:"}
|
||||
|
||||
|
||||
def _get_json(url, timeout=30):
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.load(resp)
|
||||
|
||||
|
||||
def _humanize(seconds):
|
||||
seconds = int(max(seconds, 0))
|
||||
d, rem = divmod(seconds, 86400)
|
||||
h, rem = divmod(rem, 3600)
|
||||
m, _ = divmod(rem, 60)
|
||||
if d:
|
||||
return "%dd%dh" % (d, h)
|
||||
if h:
|
||||
return "%dh%dm" % (h, m)
|
||||
if m:
|
||||
return "%dm" % m
|
||||
return "<1m"
|
||||
|
||||
|
||||
def _now_utc():
|
||||
return datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
|
||||
def _age(starts_at):
|
||||
if not starts_at:
|
||||
return ""
|
||||
try:
|
||||
ts = starts_at.replace("Z", "+00:00")
|
||||
# Trim sub-second precision beyond microseconds if present.
|
||||
started = datetime.datetime.fromisoformat(ts)
|
||||
return _humanize((_now_utc() - started).total_seconds())
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
def fetch_current_from_alertmanager():
|
||||
"""Active, non-silenced, non-inhibited alerts with their summaries."""
|
||||
q = urllib.parse.urlencode(
|
||||
{"active": "true", "silenced": "false", "inhibited": "false", "unprocessed": "false"}
|
||||
)
|
||||
data = _get_json("%s/api/v2/alerts?%s" % (ALERTMANAGER_URL, q))
|
||||
alerts = []
|
||||
for a in data:
|
||||
if a.get("status", {}).get("state") != "active":
|
||||
continue
|
||||
labels = a.get("labels", {})
|
||||
ann = a.get("annotations", {})
|
||||
alerts.append(
|
||||
{
|
||||
"alertname": labels.get("alertname", "?"),
|
||||
"severity": (labels.get("severity") or "info").lower(),
|
||||
"lane": labels.get("lane", ""),
|
||||
"summary": ann.get("summary", ""),
|
||||
"age": _age(a.get("startsAt", "")),
|
||||
}
|
||||
)
|
||||
return alerts
|
||||
|
||||
|
||||
def fetch_current_from_prometheus():
|
||||
"""Fallback: firing alerts from Prometheus (no summaries, includes inhibited)."""
|
||||
url = "%s/api/v1/query?%s" % (
|
||||
PROMETHEUS_URL,
|
||||
urllib.parse.urlencode({"query": 'ALERTS{alertstate="firing"}'}),
|
||||
)
|
||||
data = _get_json(url)
|
||||
alerts = []
|
||||
for s in data.get("data", {}).get("result", []):
|
||||
m = s.get("metric", {})
|
||||
alerts.append(
|
||||
{
|
||||
"alertname": m.get("alertname", "?"),
|
||||
"severity": (m.get("severity") or "info").lower(),
|
||||
"lane": m.get("lane", ""),
|
||||
"summary": "",
|
||||
"age": "",
|
||||
}
|
||||
)
|
||||
return alerts
|
||||
|
||||
|
||||
def fetch_resolved_last_24h(active_names):
|
||||
"""Alertnames that fired in the last 24h but are not firing now."""
|
||||
try:
|
||||
url = "%s/api/v1/query?%s" % (
|
||||
PROMETHEUS_URL,
|
||||
urllib.parse.urlencode(
|
||||
{"query": 'count by (alertname) (max_over_time(ALERTS{alertstate="firing"}[24h]))'}
|
||||
),
|
||||
)
|
||||
data = _get_json(url)
|
||||
seen = {s["metric"].get("alertname", "?") for s in data.get("data", {}).get("result", [])}
|
||||
return sorted(seen - active_names)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def build_message(alerts, resolved):
|
||||
today = _now_utc().strftime("%a %d %b %Y")
|
||||
by_sev = {s: [] for s in SEV_ORDER}
|
||||
for a in alerts:
|
||||
by_sev.setdefault(a["severity"], []).append(a)
|
||||
|
||||
n = len(alerts)
|
||||
counts = " ".join("%s %d" % (s, len(by_sev.get(s, []))) for s in SEV_ORDER if by_sev.get(s))
|
||||
|
||||
if n == 0:
|
||||
header = ":white_check_mark: *Daily alert digest* — %s\nAll clear: nothing firing." % today
|
||||
else:
|
||||
header = ":bar_chart: *Daily alert digest* — %s\nFiring now: *%d*%s" % (
|
||||
today,
|
||||
n,
|
||||
(" (" + counts + ")") if counts else "",
|
||||
)
|
||||
|
||||
lines = [header]
|
||||
for sev in SEV_ORDER:
|
||||
items = sorted(by_sev.get(sev, []), key=lambda a: a["alertname"])
|
||||
if not items:
|
||||
continue
|
||||
lines.append("")
|
||||
lines.append("%s *%s (%d)*" % (SEV_EMOJI.get(sev, ""), sev.capitalize(), len(items)))
|
||||
for a in items:
|
||||
lock = ":lock: " if a["lane"] == "security" else ""
|
||||
age = (" _(%s)_" % a["age"]) if a["age"] else ""
|
||||
summary = (" — %s" % a["summary"]) if a["summary"] else ""
|
||||
lines.append("• %s*%s*%s%s" % (lock, a["alertname"], summary, age))
|
||||
|
||||
# Any non-standard severities (defensive — shouldn't happen).
|
||||
extra = [s for s in by_sev if s not in SEV_ORDER and by_sev[s]]
|
||||
for sev in sorted(extra):
|
||||
lines.append("")
|
||||
lines.append("*%s (%d)*" % (sev, len(by_sev[sev])))
|
||||
for a in sorted(by_sev[sev], key=lambda a: a["alertname"]):
|
||||
lines.append("• *%s* — %s" % (a["alertname"], a["summary"]))
|
||||
|
||||
if resolved:
|
||||
lines.append("")
|
||||
lines.append(":white_check_mark: Resolved in last 24h (%d): %s" % (len(resolved), ", ".join(resolved)))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def post_to_slack(text):
|
||||
payload = {"channel": SLACK_CHANNEL, "text": text}
|
||||
if DRY_RUN:
|
||||
print("[DRY_RUN] would POST to Slack channel %s:\n%s" % (SLACK_CHANNEL, text))
|
||||
return
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
SLACK_WEBHOOK_URL, data=body, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
if resp.status >= 300:
|
||||
raise RuntimeError("Slack POST failed: HTTP %d" % resp.status)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
alerts = fetch_current_from_alertmanager()
|
||||
source = "alertmanager"
|
||||
except Exception as e:
|
||||
sys.stderr.write("alertmanager fetch failed (%s); falling back to prometheus\n" % e)
|
||||
alerts = fetch_current_from_prometheus()
|
||||
source = "prometheus-fallback"
|
||||
|
||||
active_names = {a["alertname"] for a in alerts}
|
||||
resolved = fetch_resolved_last_24h(active_names)
|
||||
text = build_message(alerts, resolved)
|
||||
sys.stderr.write("digest: %d firing (source=%s), %d resolved-24h\n" % (len(alerts), source, len(resolved)))
|
||||
post_to_slack(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
127
stacks/monitoring/modules/monitoring/alert_digest.tf
Normal file
127
stacks/monitoring/modules/monitoring/alert_digest.tf
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# =============================================================================
|
||||
# Daily alert digest -> #alerts Slack
|
||||
# =============================================================================
|
||||
# Companion to the "alert on change" routing model (alert-noise-reduction
|
||||
# 2026-06-12). Warning/info alerts no longer re-notify while they stay firing
|
||||
# (repeat_interval is effectively off) and criticals only re-ping every 6h, so
|
||||
# Slack reflects *changes*, not steady state. This CronJob is the safety net:
|
||||
# once a day it posts the full current board of firing alerts grouped by
|
||||
# severity (+ what cleared in the last 24h) so the standing state is reviewed
|
||||
# on a schedule, the way the #security lane is skimmed daily.
|
||||
#
|
||||
# Implementation: stock python:3.12-alpine running a pure-stdlib script
|
||||
# (alert_digest.py, mounted from a ConfigMap). NO pip/apk at runtime — once a
|
||||
# day, zero per-run package-install disk writes (the footprint that got
|
||||
# status-page-pusher disabled, memory id=559). Queries the in-cluster
|
||||
# Alertmanager v2 API for the current board (respects silences + inhibitions)
|
||||
# and Prometheus for the resolved-in-24h line.
|
||||
# =============================================================================
|
||||
|
||||
resource "kubernetes_config_map" "alert_digest_script" {
|
||||
metadata {
|
||||
name = "alert-digest-script"
|
||||
namespace = kubernetes_namespace.monitoring.metadata[0].name
|
||||
}
|
||||
data = {
|
||||
"alert_digest.py" = file("${path.module}/alert_digest.py")
|
||||
}
|
||||
}
|
||||
|
||||
# Reuses the same Slack incoming-webhook the Alertmanager receivers post with
|
||||
# (var.alertmanager_slack_api_url) — no new webhook, just a namespaced Secret so
|
||||
# the URL isn't a literal in the pod spec.
|
||||
resource "kubernetes_secret" "alert_digest" {
|
||||
metadata {
|
||||
name = "alert-digest"
|
||||
namespace = kubernetes_namespace.monitoring.metadata[0].name
|
||||
}
|
||||
type = "Opaque"
|
||||
data = {
|
||||
SLACK_WEBHOOK_URL = var.alertmanager_slack_api_url
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_cron_job_v1" "alert_digest" {
|
||||
metadata {
|
||||
name = "alert-digest"
|
||||
namespace = kubernetes_namespace.monitoring.metadata[0].name
|
||||
labels = {
|
||||
app = "alert-digest"
|
||||
tier = var.tier
|
||||
}
|
||||
}
|
||||
spec {
|
||||
concurrency_policy = "Forbid"
|
||||
failed_jobs_history_limit = 3
|
||||
successful_jobs_history_limit = 3
|
||||
schedule = "0 8 * * *"
|
||||
timezone = "Europe/London"
|
||||
starting_deadline_seconds = 600
|
||||
job_template {
|
||||
metadata {}
|
||||
spec {
|
||||
backoff_limit = 2
|
||||
ttl_seconds_after_finished = 86400
|
||||
template {
|
||||
metadata {
|
||||
labels = {
|
||||
app = "alert-digest"
|
||||
}
|
||||
}
|
||||
spec {
|
||||
restart_policy = "OnFailure"
|
||||
container {
|
||||
name = "alert-digest"
|
||||
image = "docker.io/library/python:3.12-alpine"
|
||||
image_pull_policy = "IfNotPresent"
|
||||
command = ["python3", "/scripts/alert_digest.py"]
|
||||
env {
|
||||
name = "SLACK_WEBHOOK_URL"
|
||||
value_from {
|
||||
secret_key_ref {
|
||||
name = kubernetes_secret.alert_digest.metadata[0].name
|
||||
key = "SLACK_WEBHOOK_URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
env {
|
||||
name = "SLACK_CHANNEL"
|
||||
value = "#alerts"
|
||||
}
|
||||
volume_mount {
|
||||
name = "script"
|
||||
mount_path = "/scripts"
|
||||
read_only = true
|
||||
}
|
||||
resources {
|
||||
requests = {
|
||||
cpu = "10m"
|
||||
memory = "48Mi"
|
||||
}
|
||||
limits = {
|
||||
memory = "96Mi"
|
||||
}
|
||||
}
|
||||
}
|
||||
volume {
|
||||
name = "script"
|
||||
config_map {
|
||||
name = kubernetes_config_map.alert_digest_script.metadata[0].name
|
||||
}
|
||||
}
|
||||
dns_config {
|
||||
option {
|
||||
name = "ndots"
|
||||
value = "2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle {
|
||||
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
|
||||
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
|
||||
}
|
||||
}
|
||||
|
|
@ -51,7 +51,12 @@ alertmanager:
|
|||
group_by: ["alertname"]
|
||||
group_wait: 30s
|
||||
group_interval: 5m
|
||||
repeat_interval: 4h
|
||||
# alert-on-change (2026-06-12): warning/info no longer re-notify while they
|
||||
# stay firing — a 1y repeat is effectively "notify once, then only on a
|
||||
# membership change or resolve". The daily alert-digest CronJob
|
||||
# (alert_digest.tf) carries standing state. Criticals keep a slow re-ping
|
||||
# (see the severity=critical child route).
|
||||
repeat_interval: 8760h
|
||||
receiver: slack-warning
|
||||
routes:
|
||||
# Wave 1 security lane — matches alerts that set `lane = "security"`
|
||||
|
|
@ -68,14 +73,19 @@ alertmanager:
|
|||
- receiver: slack-critical
|
||||
group_wait: 10s
|
||||
group_interval: 1m
|
||||
repeat_interval: 1h
|
||||
# alert-on-change tier (2026-06-12): criticals still re-ping, but every
|
||||
# 6h not 1h — a slow nag so a real prod-down issue isn't forgotten,
|
||||
# minus the hourly drip. Warnings/info don't re-ping at all.
|
||||
repeat_interval: 6h
|
||||
matchers:
|
||||
- severity = critical
|
||||
continue: false
|
||||
- receiver: slack-info
|
||||
group_wait: 5m
|
||||
group_interval: 30m
|
||||
repeat_interval: 12h
|
||||
# alert-on-change (2026-06-12): info never re-pings while firing; the
|
||||
# daily digest carries standing state.
|
||||
repeat_interval: 8760h
|
||||
matchers:
|
||||
- severity = info
|
||||
continue: false
|
||||
|
|
@ -175,6 +185,28 @@ alertmanager:
|
|||
- alertname = KubeletImagePullErrors
|
||||
target_matchers:
|
||||
- alertname =~ "PodsStuckContainerCreating|DeploymentReplicasMismatch|StatefulSetReplicasMismatch|DaemonSetMissingPods"
|
||||
# A node under DiskPressure/MemoryPressure/PIDPressure evicts pods and
|
||||
# stalls image GC + pulls — the resulting pod-churn alerts are downstream
|
||||
# symptoms. The 2026-06-12 midday cascade fired 25 PodCrashLooping + 14
|
||||
# PodImagePullBackOff uninhibited because only NodeDown (not node pressure)
|
||||
# was a source. NodeConditionBad already matches all three pressures; keep
|
||||
# NodeDiskPressure too for redundancy. No `equal` (matches the NodeDown
|
||||
# rule above): the pod-churn alertnames carry no `node` label, and a
|
||||
# multi-node pressure event is exactly when cluster-wide suppression helps.
|
||||
- source_matchers:
|
||||
- alertname =~ "NodeConditionBad|NodeDiskPressure"
|
||||
target_matchers:
|
||||
- alertname =~ "PodCrashLooping|ContainerOOMKilled|PodImagePullBackOff|PodsStuckContainerCreating|ScrapeTargetDown|DeploymentReplicasMismatch|StatefulSetReplicasMismatch|DaemonSetMissingPods"
|
||||
# Two t3 path-probe alerts describe one leg-down condition: T3ProbeLegDown
|
||||
# (connected==0, root) and T3ProbeDropBurst (>6 disconnects/15m, symptom).
|
||||
# A down leg both reads disconnected AND disconnects repeatedly — suppress
|
||||
# the burst for that same leg so one alert fires, not two. ~3,400 alert-min
|
||||
# of overlap in the 24h before this (the #1 noise source).
|
||||
- source_matchers:
|
||||
- alertname = T3ProbeLegDown
|
||||
target_matchers:
|
||||
- alertname = T3ProbeDropBurst
|
||||
equal: [leg]
|
||||
receivers:
|
||||
- name: slack-critical
|
||||
slack_configs:
|
||||
|
|
@ -501,6 +533,16 @@ serverFiles:
|
|||
regex: true
|
||||
source_labels:
|
||||
- __meta_kubernetes_service_annotation_prometheus_io_scrape_slow
|
||||
# Only scrape Ready endpoints. Completed CronJob pods linger in the
|
||||
# service's EndpointSlice as NotReady addresses (their labels still
|
||||
# match the Service selector) and were scraped as down targets,
|
||||
# firing ScrapeTargetDown false positives (tts/tripit/beads, memory
|
||||
# id=4895). Dropping NotReady here kills that whole class; a Ready pod
|
||||
# whose metrics endpoint is genuinely broken still fires.
|
||||
- action: keep
|
||||
regex: true
|
||||
source_labels:
|
||||
- __meta_kubernetes_endpoint_ready
|
||||
- action: replace
|
||||
regex: (https?)
|
||||
source_labels:
|
||||
|
|
@ -554,6 +596,12 @@ serverFiles:
|
|||
regex: true
|
||||
source_labels:
|
||||
- __meta_kubernetes_service_annotation_prometheus_io_scrape_slow
|
||||
# Only scrape Ready endpoints — see kubernetes-service-endpoints above
|
||||
# (drops lingering completed-CronJob NotReady pods → no ScrapeTargetDown FP).
|
||||
- action: keep
|
||||
regex: true
|
||||
source_labels:
|
||||
- __meta_kubernetes_endpoint_ready
|
||||
- action: replace
|
||||
regex: (https?)
|
||||
source_labels:
|
||||
|
|
@ -1634,7 +1682,9 @@ serverFiles:
|
|||
summary: "LVM PVC snapshot job has never reported metrics to Pushgateway"
|
||||
- alert: LVMSnapshotFailing
|
||||
expr: lvm_snapshot_last_status{job="lvm-pvc-snapshot"} != 0
|
||||
for: 0m
|
||||
# for: 5m (was 0m) — ride out a single Pushgateway status flip; a real
|
||||
# failure persists until the next run overwrites it (alert-noise 2026-06-12)
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
|
|
@ -1656,7 +1706,7 @@ serverFiles:
|
|||
summary: "Daily backup is {{ $value | humanizeDuration }} old (threshold: 9d)"
|
||||
- alert: WeeklyBackupFailing
|
||||
expr: daily_backup_last_status{job="daily-backup"} != 0
|
||||
for: 0m
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
|
|
@ -1677,7 +1727,7 @@ serverFiles:
|
|||
summary: "Offsite backup sync is {{ $value | humanizeDuration }} old (threshold: 9d)"
|
||||
- alert: OffsiteBackupSyncFailing
|
||||
expr: offsite_sync_last_status{job="offsite-backup-sync"} != 0
|
||||
for: 0m
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
|
|
@ -1691,7 +1741,7 @@ serverFiles:
|
|||
summary: "NFS local mirror to sda is {{ $value | humanizeDuration }} old (threshold: 16d / 2 weekly cycles)"
|
||||
- alert: NfsMirrorFailing
|
||||
expr: nfs_mirror_last_status{job="nfs-mirror"} != 0
|
||||
for: 0m
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
|
|
@ -1713,7 +1763,7 @@ serverFiles:
|
|||
summary: "vzdump VM image backup job has never reported metrics to Pushgateway"
|
||||
- alert: VzdumpBackupFailing
|
||||
expr: vzdump_last_status{job="vzdump-backup"} != 0
|
||||
for: 0m
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
|
|
@ -2724,7 +2774,9 @@ serverFiles:
|
|||
# current value (fresh /tmp each run), so the alert could never fire.
|
||||
- alert: DNSQuerySpike
|
||||
expr: dns_anomaly_total_queries > 2 * avg_over_time(dns_anomaly_total_queries[1h] offset 15m) and dns_anomaly_total_queries > 1000
|
||||
for: 0m
|
||||
# for: 5m (was 0m) — a single 15-min scrape spike self-clears; a real
|
||||
# spike persists across scrapes (alert-noise 2026-06-12)
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
|
|
@ -2738,7 +2790,7 @@ serverFiles:
|
|||
summary: "DNS query volume dropped: {{ $value | printf \"%.0f\" }} queries (<50% of 1h avg) — upstream clients may be failing to reach Technitium"
|
||||
- alert: DNSHighErrorRate
|
||||
expr: dns_anomaly_server_failure > 100
|
||||
for: 0m
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
|
|
@ -3085,6 +3137,16 @@ serverFiles:
|
|||
description: "The must-stay-public URL {{ $labels.instance }} (carve-out `{{ $labels.service }}`) is failing its blackbox probe — most likely it now 302-redirects to Authentik SSO. A path-scoped `auth = \"none\"` carve-out probably regressed (TF revert / deploy / ingress_factory auth default flipping back to \"required\"). Native-client / public / webhook / WebSocket / SPA-XHR traffic to this endpoint is broken for strangers and machines. Check the owning stack's ingress_factory `auth` + `ingress_path`, and curl the URL: `curl -sI '{{ $labels.instance }}'` — a Location to authentik.viktorbarzin.me confirms the regression. Probe config + target list: stacks/monitoring/modules/monitoring/authentik_walloff_probe.tf."
|
||||
|
||||
extraScrapeConfigs: |
|
||||
# Alertmanager self-metrics. The bundled Alertmanager Service carries no
|
||||
# prometheus.io/scrape annotation, so kubernetes-service-endpoints never
|
||||
# discovered it — there was NO alertmanager_notifications_total / _alerts /
|
||||
# _notifications_failed_total series until this job (alert-noise-reduction
|
||||
# 2026-06-12), i.e. notification volume was unmeasurable. Static target = the
|
||||
# in-cluster Alertmanager service.
|
||||
- job_name: 'alertmanager'
|
||||
static_configs:
|
||||
- targets:
|
||||
- 'prometheus-alertmanager.monitoring.svc.cluster.local:9093'
|
||||
# Authentik walling-off guard. Probes each must-stay-public carve-out URL via
|
||||
# blackbox-exporter's `http_no_authentik_redirect` module (no_follow_redirects +
|
||||
# fail_if_header_matches on a Location -> Authentik). probe_success == 0 for a
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue