CX Triage: alert diagnosis over the CX-Tools collectors

Read-only triage for the Infrahub error alerts. Pulls the Prometheus alert
queue, re-checks each alert's condition against live state to separate real
work from noise, diagnoses it using the CX runbooks, and drafts the customer
comms with contacts resolved from Infrahub.

Findings from validating against production:
- "Suspected Rogue VM" fires on spare GPU capacity, not rogue VMs: In_Use_Gpus
  equals the physical count on 71 of 75 firing hosts, so the rule reduces to
  "this host has a free GPU". Verified against OpenStack on 10 hosts.
- "Exists in Infrahub but does not exist in OpenStack" matches every VM because
  openstack_nova_server_status returns no series; excluded as a rule defect.
- Prometheus activeAt is reset several times a day by dips in the Resources
  metric, so alert ages are recovered from ALERTS history instead.

Takes ~2,650 firing alerts down to ~20 that need a decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 06:48:34 +01:00
commit a039e0b5fd
23 changed files with 7192 additions and 0 deletions

437
triagelib/server.py Normal file
View File

@@ -0,0 +1,437 @@
"""Localhost HTTP server: alert queue, background triage jobs, JSON API."""
from __future__ import annotations
import json
import subprocess
import threading
import time
import traceback
import urllib.parse
import uuid
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Optional
from . import (VERSION, alerts as alertlib, comms, cxbridge, integrations, linkage,
runbooks, screening, settings as settings_mod, ui, ui_linkage,
ui_settings, ui_v2)
from .prometheus import (
AlertCache, PrometheusClient, PrometheusError, RuleIndex, StateSnapshot, TrueAgeIndex,
parse_alert_text,
)
TRIAGE_WORKERS = 3
JOB_TTL_SECONDS = 30 * 60
class Jobs:
"""In-memory triage jobs. Diagnosis takes tens of seconds, so the UI polls."""
def __init__(self, workers: int = TRIAGE_WORKERS):
self._pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="triage")
self._lock = threading.Lock()
self._jobs: dict[str, dict[str, Any]] = {}
def submit(self, alert: alertlib.Alert, prom: PrometheusClient,
snap: Any = None, force: bool = False, user_settings: Any = None) -> str:
job_id = uuid.uuid4().hex[:12]
with self._lock:
self._reap()
self._jobs[job_id] = {
"id": job_id,
"state": "running",
"created": time.time(),
"alert": alert.to_json(),
"result": None,
"error": "",
}
self._pool.submit(self._run, job_id, alert, prom, snap, force, user_settings)
return job_id
def _run(self, job_id: str, alert: alertlib.Alert, prom: PrometheusClient,
snap: Any = None, force: bool = False, user_settings: Any = None) -> None:
started = time.monotonic()
try:
diagnosis = runbooks.diagnose(alert, prom, snap, force, user_settings)
payload = diagnosis.to_json()
payload["elapsed_seconds"] = round(time.monotonic() - started, 1)
with self._lock:
job = self._jobs.get(job_id)
if job is not None:
job.update({"state": "done", "result": payload})
except Exception:
with self._lock:
job = self._jobs.get(job_id)
if job is not None:
job.update({"state": "error", "error": traceback.format_exc(limit=4)})
def get(self, job_id: str) -> Optional[dict[str, Any]]:
with self._lock:
job = self._jobs.get(job_id)
return dict(job) if job else None
def _reap(self) -> None:
cutoff = time.time() - JOB_TTL_SECONDS
for key in [k for k, v in self._jobs.items() if v["created"] < cutoff]:
self._jobs.pop(key, None)
class App:
def __init__(self, prom: PrometheusClient):
self.prom = prom
self.cache = AlertCache(prom)
self.rules = RuleIndex(prom)
self.snapshot = StateSnapshot(prom)
self.true_age = TrueAgeIndex(prom)
self.jobs = Jobs()
self.scan = linkage.Scan()
self.settings = settings_mod.Settings()
def warm(self, log=print) -> None:
"""Populate the caches before serving.
Recovering true alert ages reads a week of ALERTS history, so doing it
lazily would make the first page load take ~20 seconds.
"""
try:
self.rules.ensure()
log(f" rule index: {self.rules.count} alerting rule(s)")
snap = self.snapshot.get()
log(f" state snapshot: {len(snap.by_openstack_id)} VMs, {len(snap.total_gpus)} hosts")
if snap.pipeline_dips:
log(f" {len(snap.pipeline_dips)} Resources metric dip(s) in the last 24h "
"- alert ages will be recovered from history")
ages = self.true_age.get()
log(f" alert history: {ages.count} alert(s) indexed over {ages.WINDOW_DAYS} days")
except PrometheusError as exc:
log(f" WARN: could not warm Prometheus caches: {exc}")
# --- endpoints ---------------------------------------------------------
def health(self) -> dict[str, Any]:
checks: list[dict[str, Any]] = []
def add(name: str, ok: bool, detail: str) -> None:
checks.append({"name": name, "ok": ok, "detail": detail})
try:
path = cxbridge.cx_tools_path()
add("CX-Tools", True, path)
except cxbridge.BridgeError as exc:
add("CX-Tools", False, str(exc))
try:
cfg = cxbridge.config()
add("API credentials", True, "Infrahub and InfraInsight keys loaded from 1Password")
add("Infrahub endpoint", True, cfg.infrahub_base)
except cxbridge.BridgeError as exc:
add("API credentials", False, str(exc))
try:
rc, out, _err = _run(["docker", "ps", "--format", "{{.Names}}"], 10)
running = {x.strip() for x in out.splitlines() if x.strip()} if rc == 0 else set()
expected = {"ca1-osc", "ca2-osc", "us1-osc", "no1-osc"}
missing = sorted(expected - running)
add("OpenStack CLI containers", not missing,
"all present" if not missing else f"not running: {', '.join(missing)}")
except Exception as exc:
add("OpenStack CLI containers", False, str(exc))
try:
add("Prometheus", True, f"{self.prom.base} ({self.prom.describe_transport()})")
except PrometheusError as exc:
add("Prometheus", False, str(exc))
return {"version": VERSION, "ok": all(c["ok"] for c in checks), "checks": checks}
def alert_queue(self, force: bool = False) -> dict[str, Any]:
raw, error, age = self.cache.get(force=force)
snap = self.snapshot.get()
ages = self.true_age.get()
parsed = [alertlib.from_prometheus(a, self.rules, ages) for a in raw]
excluded = [a for a in parsed if alertlib.is_excluded(a)]
candidates = [a for a in parsed if not alertlib.is_excluded(a)]
cx = [a for a in candidates if a.category == "cx" and alertlib.cx_relevant(a)]
screening.screen_all(cx, snap, self.settings)
# Everything that is not a CX runbook alert: node-exporter in its own
# section, then the rest of the platform rules. Split by identity, since
# two distinct alerts can compare equal field-for-field.
in_cx = {id(a) for a in cx}
infra = [a for a in candidates if id(a) not in in_cx]
infra.sort(key=alertlib.sort_key)
return {
"error": error or snap.error or ages.error,
"cache_age_seconds": round(age, 1),
"warnings": screening.health_warnings(snap),
"totals": {
"prometheus": len(parsed),
"cx": len(cx),
"infrastructure": len(infra),
"excluded": len(excluded),
},
"excluded_note": (
f"{len(excluded)} '{', '.join(sorted({a.alertname for a in excluded}))}' alerts hidden"
if excluded else ""
),
"integrations": {"zendesk": integrations.zendesk_configured(),
"jira": integrations.jira_configured()},
"summary": screening.summarize(cx),
"groups": alertlib.group_alerts(cx),
"infrastructure": _infra_sections(infra),
}
def triage(self, labels: dict[str, str], annotations: Optional[dict[str, str]] = None,
state: str = "firing", active_at: Any = None, force: bool = False) -> dict[str, Any]:
alert = alertlib.from_labels(labels, annotations, state=state, active_at=active_at)
if alert.kind == "excluded":
return {"error": f"'{alertlib.clean_alertname(alert.alertname)}' is excluded as a monitoring fault."}
if alert.kind == "other":
return {"error": f"'{alertlib.clean_alertname(alert.alertname)}' is not an alert type the CX runbooks cover."}
meta = self.rules.get(alert.alertname)
if meta:
alert.rule_file = str(meta.get("file") or "")
alert.rule_group = str(meta.get("group") or "")
alert.for_seconds = int(meta.get("for_seconds") or 0)
snap = self.snapshot.get()
alert.true_age_minutes, alert.true_age_capped = self.true_age.get().lookup(alert.labels)
alert.screen = screening.screen(alert, snap, self.settings)
job_id = self.jobs.submit(alert, self.prom, snap, force, self.settings)
return {"job_id": job_id, "alert": alert.to_json()}
def triage_by_id(self, alert_id: str, force: bool = False) -> dict[str, Any]:
raw, error, _age = self.cache.get()
if error and not raw:
return {"error": error}
for item in raw:
alert = alertlib.from_prometheus(item, self.rules, self.true_age.get())
if alert.fingerprint() == alert_id:
return self.triage(alert.labels, alert.annotations, alert.state,
item.get("activeAt"), force=force)
return {"error": "That alert is no longer firing. Refresh the queue."}
def parse(self, text: str) -> dict[str, Any]:
found = parse_alert_text(text)
if not found:
return {"error": "Could not find any label set in that text. Paste an ALERTS{...} line or a Prometheus graph URL."}
out = []
for labels in found:
alert = alertlib.from_labels(labels)
out.append({"alert": alert.to_json(), "supported": alertlib.cx_relevant(alert)})
return {"parsed": out}
def settings_payload(self) -> dict[str, Any]:
return self.settings.to_json()
def _current_alerts(self) -> list[Any]:
raw, _error, _age = self.cache.get()
return [a for a in (alertlib.from_prometheus(x, self.rules) for x in raw)
if not alertlib.is_excluded(a) and alertlib.cx_relevant(a)]
def settings_action(self, body: dict[str, Any]) -> dict[str, Any]:
action = str(body.get("action") or "")
if action == "save_rule":
rule = self.settings.upsert_rule(body.get("rule") or {})
return {"ok": True, "rule": rule, "settings": self.settings.to_json()}
if action == "delete_rule":
self.settings.delete_rule(str(body.get("id") or ""))
return {"ok": True, "settings": self.settings.to_json()}
if action == "toggle_rule":
self.settings.toggle_rule(str(body.get("id") or ""), bool(body.get("enabled")))
return {"ok": True, "settings": self.settings.to_json()}
if action == "general":
self.settings.set_general(body.get("agent_name"), body.get("chronic_days"))
return {"ok": True, "settings": self.settings.to_json()}
if action == "preview":
hits = settings_mod.preview(self.settings, body.get("rule") or {}, self._current_alerts())
return {"ok": True, "matches": hits, "count": len(hits)}
return {"ok": False, "error": f"Unknown action: {action}"}
def start_scan(self) -> dict[str, Any]:
if self.scan.state == "running":
return {"started": False, "reason": "already running"}
snap = self.snapshot.get()
threading.Thread(target=self.scan.run, args=(snap,), daemon=True).start()
return {"started": True}
def send_zendesk(self, body: dict[str, Any]) -> dict[str, Any]:
"""Deliberately refuses until Zendesk is configured AND enabled.
Contacting a customer is the one thing this tool must never do as a
side effect, so delivery stays behind explicit configuration rather
than being reachable from the UI by default.
"""
if not integrations.zendesk_configured():
return {"ok": False, "error": "Zendesk is not configured. Set CX_ZENDESK_SUBDOMAIN, "
"CX_ZENDESK_EMAIL and CX_ZENDESK_TOKEN, then restart."}
return {"ok": False, "error": "Sending is not enabled in this build. The payload is ready; "
"wiring delivery is a deliberate, separate step."}
def templates(self) -> dict[str, Any]:
return {"templates": [d.to_json() for d in (comms.draft(k) for k in comms._TEMPLATES) if d]}
SOURCE_LABELS = {
"node-exporter-rules.yml": "Node exporter (hosts)",
"ceph-rules.yml": "Ceph",
"mysql-rules.yml": "MySQL",
"mysql-performance-rules.yml": "MySQL performance",
"galera-rules.yml": "Galera",
"openstack-rules.yml": "OpenStack services",
"blackbox.yml": "Blackbox / OOB",
"infrahub-rules.yml": "Infrahub (no CX runbook)",
}
def _infra_sections(items: list[alertlib.Alert]) -> list[dict[str, Any]]:
"""Group infrastructure alerts by their rule file, node-exporter first."""
buckets: dict[str, list[alertlib.Alert]] = {}
for alert in items:
buckets.setdefault(alert.rule_file or "unknown", []).append(alert)
sections = []
for source, members in buckets.items():
by_name: dict[str, int] = {}
for alert in members:
name = alertlib.clean_alertname(alert.alertname)
by_name[name] = by_name.get(name, 0) + 1
sections.append({
"source": source,
"label": SOURCE_LABELS.get(source, source),
"total": len(members),
"by_alertname": sorted(({"name": k, "count": v} for k, v in by_name.items()),
key=lambda x: (-x["count"], x["name"])),
"alerts": [a.to_json() for a in members],
})
sections.sort(key=lambda s: (s["source"] != alertlib.NODE_RULE_FILE, -s["total"]))
return sections
def _run(cmd: list[str], timeout: int) -> tuple[int, str, str]:
try:
p = subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
return p.returncode, p.stdout, p.stderr
except Exception as exc:
return 1, "", str(exc)
class Handler(BaseHTTPRequestHandler):
server_version = f"cx-triage/{VERSION}"
app: App
def log_message(self, fmt: str, *args: Any) -> None:
if self.path.startswith("/api/jobs/"):
return
print(f" {self.command} {self.path}")
# --- helpers ----------------------------------------------------------
def _send_json(self, payload: Any, code: int = 200) -> None:
body = json.dumps(payload, default=str).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _send_html(self, html: str) -> None:
body = html.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
try:
return json.loads(self.rfile.read(length).decode("utf-8", errors="replace")) or {}
except json.JSONDecodeError:
return {}
# --- routes -----------------------------------------------------------
def do_GET(self) -> None: # noqa: N802
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
query = urllib.parse.parse_qs(parsed.query)
try:
if path in ("/", "/index.html"):
self._send_html(ui_v2.PAGE)
elif path == "/classic":
self._send_html(ui.PAGE)
elif path == "/settings":
self._send_html(ui_settings.PAGE)
elif path == "/api/settings":
self._send_json(self.app.settings_payload())
elif path == "/linkage":
self._send_html(ui_linkage.PAGE)
elif path == "/api/linkage":
self._send_json(self.app.scan.to_json())
elif path == "/api/health":
self._send_json(self.app.health())
elif path == "/api/alerts":
self._send_json(self.app.alert_queue(force=query.get("force", ["0"])[0] == "1"))
elif path == "/api/templates":
self._send_json(self.app.templates())
elif path.startswith("/api/jobs/"):
job = self.app.jobs.get(path.rsplit("/", 1)[-1])
self._send_json(job or {"error": "Unknown job."}, 200 if job else 404)
else:
self._send_json({"error": "Not found."}, 404)
except Exception as exc:
self._send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
def do_POST(self) -> None: # noqa: N802
path = urllib.parse.urlparse(self.path).path
body = self._body_json()
try:
force = bool(body.get("force"))
if path == "/api/triage":
if body.get("alert_id"):
self._send_json(self.app.triage_by_id(str(body["alert_id"]), force=force))
elif isinstance(body.get("labels"), dict):
self._send_json(self.app.triage(body["labels"], body.get("annotations"), force=force))
else:
self._send_json({"error": "Provide alert_id or labels."}, 400)
elif path == "/api/parse":
self._send_json(self.app.parse(str(body.get("text") or "")))
elif path == "/api/actions/zendesk":
self._send_json(self.app.send_zendesk(body))
elif path == "/api/settings":
self._send_json(self.app.settings_action(body))
elif path == "/api/linkage/scan":
self._send_json(self.app.start_scan())
elif path == "/api/linkage/enrich":
self._send_json(linkage.enrich(str(body.get("region") or ""),
str(body.get("openstack_id") or "")))
else:
self._send_json({"error": "Not found."}, 404)
except Exception as exc:
self._send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
def serve(host: str = "127.0.0.1", port: int = 8765, prometheus_base: Optional[str] = None) -> None:
from .prometheus import DEFAULT_BASE
prom = PrometheusClient(prometheus_base or DEFAULT_BASE)
app = App(prom)
print("Warming caches...")
app.warm()
Handler.app = app
httpd = ThreadingHTTPServer((host, port), Handler)
httpd.daemon_threads = True
print(f"cx-triage listening on http://{host}:{port}")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nshutting down")
finally:
httpd.server_close()