Skip to content

observability: Alertmanager's only receiver was named "null" - #1112

Open
mdheller wants to merge 3 commits into
mainfrom
feat/alert-delivery-receivers
Open

observability: Alertmanager's only receiver was named "null"#1112
mdheller wants to merge 3 commits into
mainfrom
feat/alert-delivery-receivers

Conversation

@mdheller

Copy link
Copy Markdown
Member

What was wrong

Alertmanager had exactly one receiver:

route:
  receiver: "null"
receivers:
  - name: "null"

Every alert in this estate — SLO breaches, KubeJobFailed, the PVC guard's data-loss warnings — was routed to a receiver that discards. Nothing reported a problem, because from inside Alertmanager "delivered to null" and "delivered" are the same code path. alertmanager_notifications_total rose. Dashboards were green.

At the moment this was found, Alertmanager was holding 20 firing alerts that no human had ever seen, including four KubeJobFailed and a KubeletDown.

This is the first link: every other control in this wave is decorative until it exists.

What this adds

A ~250-line stdlib-Python webhook sink. Each Alertmanager notification becomes an EvidenceReceipt (contracts/EvidenceReceipt.v0.1.json) on stdout → fluentbit-gkeGoogle Cloud Logging.

Deliberately a destination needing no credential and no external endpoint, so it could be wired without provisioning anything. Human paging is a separate decision — options and costs are in the README, and nothing here pages a person.

Why it cannot silently stop working

kube-prometheus-stack ships a Watchdog alert that fires permanently by design. Routing it here with repeat_interval: 1h gives the sink a heartbeat forever, so alert_sink_notifications_total must always rise. When it stops, AlertDeliveryDead fires.

That inverts the failure mode: silence stops meaning "quiet night" and starts meaning "alarm".

Refusals are equally loud — a malformed payload increments alert_sink_errors_total, emits a status: "failed" receipt, and returns 400. Never absorbed.

Bounds

Bound Value Why
MAX_BODY_BYTES 2 MiB Over-size → 413 + denied receipt
RECENT_RING 50 Fixed ring; no unbounded history
max_alerts 0 Never truncate — truncation drops alerts silently
Durability none Cloud Logging's job, not this process's

Proof it fires — observed, not asserted

$ curl -XPOST .../api/v2/alerts -d '[{"labels":{"alertname":"Wave1DeliveryProof", ...}}]'
POST=200
  Wave1DeliveryProof active receivers=[observability/alert-sink/alert-sink]

Receipts emitted, firing then resolved:

05:29:44  evr-alert-delivered-ee132fdb...  accepted   alert_status=firing
05:34:44  evr-alert-delivered-ae71c3a8...  succeeded  alert_status=resolved

Counters 1 → 2. Both validate against the contract (required fields, status in enum, version const).

Malformed payload also exercised: HTTP 400, errors_total 0 → 1, status: "failed" receipt.

The nine real findings from the companion rule-liveness guard were then delivered through this same path end-to-end.

Loki

Recorded in the README because it is the same defect in another costume: Loki is deployed, Running, holds a 10Gi PVC, and has zero ingested bytes and zero label values. Nothing has ever written to it. The logging story here targets Cloud Logging instead. Not addressed in this PR.

Two mechanisms, on purpose

  1. Helm values set the estate-wide default route (all namespaces).
  2. An AlertmanagerConfig CR covers namespace=observability without waiting on a chart sync — which is where every guard in this wave fires.

(2) is not a substitute for (1); alerts in socioprophet, scm, serving need the default route.

Coordination

New paths (infra/k8s/alert-delivery/**, deploy/argocd/alert-delivery.yaml) plus the Alertmanager block of deploy/argocd/observability-services.yaml. No overlap with #1091, #1105, images.yml, infra/k8s/zot/**, apps/health-twin/**, tools/, gitops-promote.yml, or validate-target-diagnostics.yml.

⚠️ Unverified by CI — GitHub Actions is at a spend cap (456 queued, 0 running). Validated locally: YAML parse, kubectl kustomize, kubectl apply --dry-run=server, and the live end-to-end run above.

Every alert in this estate was routed to a receiver that discards, and
nothing reported a problem — from inside Alertmanager, "delivered to
null" and "delivered" are the same code path. alertmanager_notifications_
total rose, dashboards stayed green, and the entire alerting corpus was
decorative.

At the time this was found Alertmanager was holding 20 firing alerts
nobody had ever seen, including four KubeJobFailed and a KubeletDown.

Adds a ~250-line stdlib-Python webhook sink that turns each notification
into an EvidenceReceipt (contracts/EvidenceReceipt.v0.1.json) on stdout,
collected by fluentbit-gke into Google Cloud Logging. No credential and
no external endpoint, so it could be wired without provisioning anything;
human paging is a separate, credentialed decision documented in the
README.

Delivery is proved continuously rather than assumed. The chart's Watchdog
alert fires forever by design, so routing it here with a 1h repeat gives
the sink a permanent heartbeat: alert_sink_notifications_total must
always rise, and AlertDeliveryDead fires when it stops. Silence becomes
the alarm instead of the default.

Two mechanisms ship together. The Helm values set the estate-wide default
route; an AlertmanagerConfig CR covers namespace=observability without
waiting on a chart sync, which is where every guard in this wave fires.

Proof it fires — observed, not asserted:

  Wave1DeliveryProof active receivers=[observability/alert-sink/alert-sink]
  05:29:44  evr-alert-delivered-ee132fdb...  accepted   firing
  05:34:44  evr-alert-delivered-ae71c3a8...  succeeded  resolved

Both receipts validate against the contract. A malformed payload was also
exercised: HTTP 400, errors_total 0 -> 1, and a status="failed" receipt
rather than silent absorption.

Note recorded in the README: Loki is deployed, Running, holds a 10Gi PVC,
and has zero ingested bytes. Nothing has ever written to it. The logging
story deliberately targets Cloud Logging instead.
…eceiver

Every alert in this PrometheusRule emitted with no namespace label, so the
AlertmanagerConfig sub-route (operator-scoped OnNamespace) would not match
them: the alert reporting 'delivery is dead' would itself be delivered to
null. The fix shipping with that defect inside it.

Caught by measuring rather than assuming. Live check after deploying the
sub-route: 19 firing alerts still routed to null, 0 to the sink, because
none carry namespace=observability. Watchdog in particular carries no
namespace label at all, so the heartbeat this rule depends on only starts
once the Helm DEFAULT route lands. Documented rather than papered over.
@mdheller

Copy link
Copy Markdown
Member Author

Correction after live verification — the fix contained the defect it fixes

Pushed as 87f7708. Reporting it here rather than quietly amending, because it is the most instructive thing this PR produced.

After deploying the sink and the AlertmanagerConfig sub-route, I measured what was actually being delivered instead of assuming:

-> alert-sink: 0     -> null only: 19

Nineteen alerts still going nowhere. Two distinct causes:

1. Watchdog carries no namespace label.

labels   : {'alertname': 'Watchdog', 'prometheus': 'observability/…', 'severity': 'none'}
receivers: ['null']

The operator scopes an AlertmanagerConfig's routes OnNamespace, so the sub-route matches only namespace=observability. Watchdog matches nothing and still routes to null. The heartbeat this PR's AlertDeliveryDead depends on is therefore not yet flowing — it only starts once the Helm default route in deploy/argocd/observability-services.yaml syncs. Until then AlertDeliveryDead fires, which is correct: estate-wide delivery genuinely is not wired.

2. Worse — the alerts in this PR's own PrometheusRule had no namespace label either.

So AlertDeliveryDead would not have matched the sub-route, and the alert announcing that alert delivery is dead would itself have been delivered to null. The fix shipping with the defect inside it.

Both now carry an explicit namespace: observability, with the reasoning recorded in the manifest so it is not "cleaned up" later. The same fix was needed in #1113's meta-rules (3c88e88) — the absent() branch there produces no labels of its own.

What is and is not proven

Claim Status
Firing + resolved delivery, receipted, contract-valid Proven live
Refusal path (400, errors_total, failed receipt) Proven live
Receipts reach Google Cloud Logging Proven live (gcloud logging read returns them)
Guard findings delivered through this path Proven live (8 alerts)
Alerts labelled namespace=observability reach the sink Proven live
Watchdog heartbeat → AlertDeliveryDead is meaningful NOT yet — needs the Helm default route to sync
Estate-wide coverage (socioprophet/scm/serving) NOT yet — same dependency

The narrow lesson: an AlertmanagerConfig CR is not a substitute for the default route, and any alert that must survive a broken delivery path has to carry the labels its own route matches on.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Adds an in-cluster Alertmanager webhook “alert-sink” receiver that emits structured EvidenceReceipt JSON lines to stdout (for Cloud Logging), plus Prometheus meta-monitoring to ensure alert delivery can’t silently fail.

Changes:

  • Introduces a Python stdlib webhook sink service with /alerts, /metrics, /healthz, and /recent.
  • Deploys Kubernetes resources (Deployment/Service/ServiceMonitor/PrometheusRule) and an Argo CD Application for alert-delivery.
  • Updates Alertmanager Helm values to route the default receiver (and Watchdog heartbeat) to the sink.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
infra/k8s/alert-delivery/base/sink.py Implements the webhook sink + receipts + metrics endpoints.
infra/k8s/alert-delivery/base/deployment.yaml Deploys the sink as a pinned python image with probes and hardened security context.
infra/k8s/alert-delivery/base/service.yaml Exposes the sink internally via ClusterIP service.
infra/k8s/alert-delivery/base/servicemonitor.yaml Enables Prometheus scraping of the sink’s /metrics.
infra/k8s/alert-delivery/base/prometheusrule-delivery.yaml Adds meta-alerting for delivery dead/sink down/rejections.
infra/k8s/alert-delivery/base/kustomization.yaml Wires base resources + configmap generation for sink.py.
infra/k8s/alert-delivery/base/alertmanagerconfig.yaml Adds namespace-scoped AlertmanagerConfig route/receiver for observability namespace.
infra/k8s/alert-delivery/README.md Documents the delivery fix, guarantees, and operational runbook.
deploy/argocd/observability-services.yaml Sets Alertmanager default route + Watchdog heartbeat to the sink.
deploy/argocd/alert-delivery.yaml Adds Argo CD Application to deploy the alert-sink base.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +5 to +9
resources:
- deployment.yaml
- service.yaml
- servicemonitor.yaml
- prometheusrule-delivery.yaml
Comment thread infra/k8s/alert-delivery/base/sink.py Outdated
Comment on lines +87 to +90
def emit(obj: dict) -> None:
"""One JSON object per line to stdout -> fluentbit-gke -> Cloud Logging."""
sys.stdout.write(canonical(obj) + "\n")
sys.stdout.flush()
Comment on lines +59 to +60
summary: "alert-sink refused {{ $value }} malformed deliveries in 15m"
description: >-
Comment on lines +93 to +99
def receipt(action: str, status: str, subject_ref: str, body: dict, **extra) -> dict:
"""An EvidenceReceipt.v0.1. Fields and enum values come from the contract."""
h = digest(body)
r = {
"version": VERSION,
"receipt_id": "evr-%s-%s" % (action, h[:32]),
"created_at": utc_now(),
Comment on lines +172 to +173
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
Comment on lines +186 to +192
def _send(self, code: int, obj: dict) -> None:
body = canonical(obj).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
Copilot AI review requested due to automatic review settings July 30, 2026 06:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (4)

infra/k8s/alert-delivery/base/sink.py:213

  • do_POST() accepts POSTs on any path, but this service is intended to be a webhook receiver (e.g. /alerts). Allowing POST to arbitrary paths makes it easier for in-cluster callers to spam receipts/metrics and pollute Cloud Logging. Consider enforcing self.path == \"/alerts\" (or a small allowlist) and returning 404 for other POST paths.
    def do_POST(self):  # noqa: N802 - stdlib hook
        length = int(self.headers.get("Content-Length") or 0)

infra/k8s/alert-delivery/base/sink.py:132

  • Any status other than exactly \"firing\" is classified as \"succeeded\", including missing/unknown values. That can produce misleading receipts and severity/status metrics while still returning 200. Since Alertmanager webhook statuses are expected to be firing or resolved, validate explicitly and raise on unexpected values so the request is rejected (400) and errors_total reflects the bad payload.
        status = str(a.get("status", "unknown"))

        # firing -> the condition is live and was delivered here.
        # resolved -> Alertmanager observed it clear.
        ev_status = "accepted" if status == "firing" else "succeeded"

infra/k8s/alert-delivery/base/deployment.yaml:32

  • With readOnlyRootFilesystem: true, CPython may attempt to create __pycache__ / .pyc and emit noisy permission errors (and extra exception overhead) at startup/runtime. To keep logs clean and behavior consistent, set PYTHONDONTWRITEBYTECODE=1 (or run python3 -B) in this container.
          image: python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
          command: ["python3", "/opt/sink/sink.py"]

infra/k8s/alert-delivery/base/prometheusrule-delivery.yaml:88

  • The comment says this rule ‘catches it in 5m’, but the expression/window is [3h] with for: 15m, so it won’t alert in ~5 minutes. Update the comment to match the actual detection time, or adjust the range/for to match the stated 5m behavior.
        # Guards the regression that started all of this: if Alertmanager is
        # reconfigured back to a null-only receiver set, notifications stop and
        # AlertDeliveryDead catches it within 3h. This catches it in 5m by
        # watching Alertmanager's own delivery counter for the sink integration.
        - alert: AlertmanagerNotNotifying
          expr: |
            increase(alertmanager_notifications_total{integration="webhook"}[3h]) == 0
            or absent(alertmanager_notifications_total{integration="webhook"})
          for: 15m

Comment on lines +5 to +9
resources:
- deployment.yaml
- service.yaml
- servicemonitor.yaml
- prometheusrule-delivery.yaml
Comment thread infra/k8s/alert-delivery/base/sink.py Outdated
Comment on lines +87 to +90
def emit(obj: dict) -> None:
"""One JSON object per line to stdout -> fluentbit-gke -> Cloud Logging."""
sys.stdout.write(canonical(obj) + "\n")
sys.stdout.flush()
…() atomic

Two orphaned-control fixes on the null-receiver PR:

1. base/alertmanagerconfig.yaml was authored but never listed in the base
   kustomization resources:, so the Argo app that syncs infra/k8s/alert-delivery/base
   never applied it. The namespace-scoped receiver route shipped as a file nobody
   read. Wired in; kubectl kustomize now renders the AlertmanagerConfig CR.

2. emit() did an unguarded sys.stdout.write()+flush() from a ThreadingHTTPServer, so
   concurrent alerts could interleave two receipts into one unparseable JSONL line —
   silent corruption of the exact receipt stream this sink exists to guarantee. A
   dedicated _emit_lock (separate from the counter _lock; no reentrancy) makes the
   write+flush atomic. Adds a deterministic concurrency test (write-splitting stdout)
   that is red on the unguarded version and green with the lock.
Copilot AI review requested due to automatic review settings July 30, 2026 17:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (4)

infra/k8s/alert-delivery/base/sink.py:224

  • This holds _lock while performing response I/O in _send(); a slow client can block other request threads from updating counters/ring or serving /metrics. Copy list(_recent) under the lock, release the lock, then call _send() outside the critical section (similar to how _metrics() snapshots state).
        if self.path.startswith("/recent"):
            with _lock:
                return self._send(200, {"recent": list(_recent)})

infra/k8s/alert-delivery/base/sink.py:143

  • Any Alertmanager status other than exactly \"firing\" is currently mapped to EvidenceReceipt status \"succeeded\", including \"unknown\" (or any unexpected value). That can produce misleading receipts for malformed/unexpected alert objects. Consider explicitly mapping only firing -> accepted and resolved -> succeeded, and treating any other status as a failure/denied condition (or emitting a distinct metrics label) so unexpected states remain loud.
        status = str(a.get("status", "unknown"))

infra/k8s/alert-delivery/base/sink.py:147

  • Any Alertmanager status other than exactly \"firing\" is currently mapped to EvidenceReceipt status \"succeeded\", including \"unknown\" (or any unexpected value). That can produce misleading receipts for malformed/unexpected alert objects. Consider explicitly mapping only firing -> accepted and resolved -> succeeded, and treating any other status as a failure/denied condition (or emitting a distinct metrics label) so unexpected states remain loud.
        ev_status = "accepted" if status == "firing" else "succeeded"

infra/k8s/alert-delivery/base/sink.py:294

  • Prometheus text exposition requires escaping label values (e.g., backslash, double-quote, and newline). Since CLUSTER comes from the environment, an unexpected value containing \\ or \" could break the /metrics endpoint. Add a small helper to escape label values per the Prometheus text format before interpolating them into metric lines.
            'alert_sink_build_info{version="%s",cluster="%s"} 1' % (VERSION, CLUSTER),



class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
Comment on lines +227 to +242
def do_POST(self): # noqa: N802 - stdlib hook
length = int(self.headers.get("Content-Length") or 0)
if length > MAX_BODY:
with _lock:
_counters["errors_total"] += 1
emit(
receipt(
"alert-delivered",
"denied",
"alert/-/<oversize>",
{"content_length": length, "limit": MAX_BODY},
)
)
return self._send(413, {"error": "body too large", "limit": MAX_BODY})

raw = self.rfile.read(length) if length else b""
@mdheller

Copy link
Copy Markdown
Member Author

Merge-gate disposition (Copilot 3-channel + adversarial pass)

FIXED in f12558e0:

  • AlertmanagerConfig orphaned from the base kustomization resources: (Copilot flagged twice, mandatory) — the authored CR would never deploy. Wired in; kubectl kustomize infra/k8s/alert-delivery/base now renders the AlertmanagerConfig.
  • emit() stdout write+flush was unguarded under ThreadingHTTPServer — concurrent alerts could interleave into one corrupt JSONL line. Now atomic under a dedicated _emit_lock. Added test_sink_emit_concurrency.py (write-splitting stdout): red on the unguarded version (JSONDecodeError), green with the lock.

Acknowledged, non-blocking (fair, low-severity): AlertSinkDown hardcodes job="alert-sink" — verify it matches the ServiceMonitor-derived label or set jobLabel; AlertmanagerNotNotifying comment says "5m" but the window is [3h]; identical Watchdog bodies produce identical receipt_id; do_POST accepts any path; a non-firing status maps to succeeded; PYTHONDONTWRITEBYTECODE. None blocks merge.

Verdict: MERGE-READY after the two fixes above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants