Skip to content

Commit e3a347c

Browse files
authored
Alerting integration (#338)
* accept alerts from alert manager and store/update them * update sqlalchemy syntax * add a reconciliation as backup for webhooks * don't allow to return to firing when a webhook arrives late after reconciler, we can ignore it * factor out alert ingestor * oauth to slack/discord * get default channel setup working * alerting: make rules super duper configurable * include slack/discord integrations and routing in audit log * add coverage for environment resolution, ensure we're "there" * preview routing coverage for notifications * dispatch alerts to slack/discord * rich preview for alerts * refactor and tidy * appease bandit, the right way * fill in the gaps, close manual actions out * notify on reaped build job, reconcile terminal notifications to ensure they're sent * enqueue after commit * tz stuff * raise retryable exception * more safely match ingress names * cleanup orphaned routes * validate channel * paginate channel list for slack * optimize application environment resolution * de-jargon alerts * allow alert reconciler to initiate notifications if last_notified is None * Allow routing only image/release/deploy failures * use advisory lock to avoid duplicate notifications * need to send scope header to mimir * re-order migrations
1 parent b22b41a commit e3a347c

39 files changed

Lines changed: 9814 additions & 59 deletions

cabotage/celery/tasks/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,12 @@
3030
refresh_tailscale_oidc_tokens, # noqa: F401
3131
teardown_tailscale_operator, # noqa: F401
3232
)
33+
34+
from .alerting import reconcile_alerts # noqa: F401
35+
36+
from .notify import (
37+
dispatch_alert_notification, # noqa: F401
38+
dispatch_pipeline_notification, # noqa: F401
39+
reconcile_notifications, # noqa: F401
40+
send_notification, # noqa: F401
41+
)

cabotage/celery/tasks/alerting.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Celery task to reconcile alerts with Alertmanager.
2+
3+
Polls the Alertmanager v2 API for all currently active alerts, upserts them
4+
into the local alerts table, and marks any locally-firing alerts that are
5+
no longer present in Alertmanager as resolved.
6+
"""
7+
8+
import logging
9+
from datetime import UTC, datetime
10+
11+
import requests
12+
from celery import shared_task
13+
from flask import current_app
14+
15+
from cabotage.server import db
16+
from cabotage.server.models.projects import Alert
17+
from cabotage.server.alerting.ingest import (
18+
_record_activity,
19+
parse_alertmanager_timestamp,
20+
upsert_alert,
21+
)
22+
from cabotage.celery.tasks.notify import dispatch_alert_notification
23+
24+
log = logging.getLogger(__name__)
25+
26+
27+
@shared_task()
28+
def reconcile_alerts():
29+
alertmanager_url = current_app.config.get("ALERTMANAGER_URL")
30+
if not alertmanager_url:
31+
return
32+
33+
verify = current_app.config.get("ALERTMANAGER_VERIFY")
34+
if verify is None:
35+
verify = True
36+
37+
headers = {"X-Scope-OrgID": current_app.config.get("MIMIR_TENANT_ID")}
38+
39+
secret = current_app.config.get("ALERTMANAGER_WEBHOOK_SECRET")
40+
if secret:
41+
headers["Authorization"] = f"Bearer {secret}"
42+
43+
try:
44+
resp = requests.get(
45+
f"{alertmanager_url.rstrip('/')}/api/v2/alerts",
46+
headers=headers,
47+
verify=verify,
48+
timeout=30,
49+
)
50+
resp.raise_for_status()
51+
except requests.RequestException:
52+
log.exception("Failed to fetch alerts from Alertmanager")
53+
return
54+
55+
active_alerts = resp.json()
56+
57+
# Track which fingerprints are still active in Alertmanager
58+
seen_fingerprints = set()
59+
dispatch_ids = []
60+
61+
for alert_data in active_alerts:
62+
labels = alert_data.get("labels", {})
63+
fingerprint = alert_data.get("fingerprint", "")
64+
65+
starts_at = parse_alertmanager_timestamp(alert_data.get("startsAt"))
66+
if starts_at:
67+
seen_fingerprints.add((fingerprint, starts_at))
68+
69+
# The v2 API nests status as {"state": "active|suppressed|..."}
70+
status = alert_data.get("status", {})
71+
if isinstance(status, dict):
72+
state = status.get("state", "active")
73+
else:
74+
state = status
75+
am_status = "firing" if state == "active" else state
76+
77+
_processed, dispatch_id = upsert_alert(
78+
fingerprint=fingerprint,
79+
status=am_status,
80+
alertname=labels.get("alertname", "unknown"),
81+
labels=labels,
82+
annotations=alert_data.get("annotations", {}),
83+
starts_at=starts_at,
84+
ends_at=parse_alertmanager_timestamp(alert_data.get("endsAt")),
85+
generator_url=alert_data.get("generatorURL"),
86+
)
87+
if dispatch_id:
88+
dispatch_ids.append(dispatch_id)
89+
90+
# Resolve any locally-firing alerts that are no longer in Alertmanager
91+
firing_alerts = Alert.query.filter_by(status="firing").all()
92+
now = datetime.now(UTC).replace(tzinfo=None)
93+
resolved_count = 0
94+
for alert in firing_alerts:
95+
if (alert.fingerprint, alert.starts_at) not in seen_fingerprints:
96+
alert.status = "resolved"
97+
alert.ends_at = now
98+
_record_activity("resolved", alert, alert.application)
99+
dispatch_ids.append(alert.id)
100+
resolved_count += 1
101+
102+
db.session.commit()
103+
104+
# Dispatch notifications after commit so workers can see the data
105+
for alert_id in dispatch_ids:
106+
dispatch_alert_notification.delay(str(alert_id))
107+
108+
log.info(
109+
"Reconciled alerts: %d active from Alertmanager, %d resolved",
110+
len(active_alerts),
111+
resolved_count,
112+
)

0 commit comments

Comments
 (0)