feat(hermes-autotriage): restart-safe incident gauge rehydration
Gauges are process-local, so a pod restart could silently drop an active human_required signal before the vmalert hold window elapsed. Metrics move to hermes_autotriage_metrics; every tick republishes gauges from stored incident state, and an incident superseded by a newer successful build is zeroed so the alert clears once the job is green again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1cbed3f6a5
commit
e89de924c0
@ -5,13 +5,21 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from prometheus_client import Counter, Gauge
|
|
||||||
|
|
||||||
from ..settings import settings
|
from ..settings import settings
|
||||||
from ..utils.logging import get_logger
|
from ..utils.logging import get_logger
|
||||||
from . import hermes_agent_client, hermes_autotriage_repair
|
from . import hermes_agent_client, hermes_autotriage_repair
|
||||||
from . import hermes_autotriage_decision as hermes_decision
|
from . import hermes_autotriage_decision as hermes_decision
|
||||||
from . import hermes_autotriage_evidence as hermes_evidence
|
from . import hermes_autotriage_evidence as hermes_evidence
|
||||||
|
from .hermes_autotriage_metrics import (
|
||||||
|
HERMES_TRIAGE_ACTION_TOTAL,
|
||||||
|
HERMES_TRIAGE_DURATION_SECONDS,
|
||||||
|
HERMES_TRIAGE_INCIDENT,
|
||||||
|
HERMES_TRIAGE_LAST_SUCCESS_TS,
|
||||||
|
INCIDENT_STATUSES,
|
||||||
|
refresh_incident_gauges,
|
||||||
|
set_incident_gauge,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@ -19,15 +27,6 @@ logger = get_logger(__name__)
|
|||||||
INCIDENT_EVENT_TYPE = "hermes_autotriage_incident"
|
INCIDENT_EVENT_TYPE = "hermes_autotriage_incident"
|
||||||
DIAGNOSIS_EVENT_TYPE = "hermes_autotriage_diagnosis"
|
DIAGNOSIS_EVENT_TYPE = "hermes_autotriage_diagnosis"
|
||||||
ACTION_EVENT_TYPE = "hermes_autotriage_action"
|
ACTION_EVENT_TYPE = "hermes_autotriage_action"
|
||||||
INCIDENT_STATUSES = (
|
|
||||||
"detected",
|
|
||||||
"diagnosed",
|
|
||||||
"repairing",
|
|
||||||
"awaiting_rebuild",
|
|
||||||
"resolved",
|
|
||||||
"human_required",
|
|
||||||
"failed",
|
|
||||||
)
|
|
||||||
EXPECTED_CLASSIFICATION = "known_demo_fixture_failure"
|
EXPECTED_CLASSIFICATION = "known_demo_fixture_failure"
|
||||||
REBUILD_FAILED_REASON = "repair rebuild failed"
|
REBUILD_FAILED_REASON = "repair rebuild failed"
|
||||||
|
|
||||||
@ -52,27 +51,6 @@ Do not perform mutations.
|
|||||||
Bundle:
|
Bundle:
|
||||||
__BUNDLE__"""
|
__BUNDLE__"""
|
||||||
|
|
||||||
HERMES_TRIAGE_INCIDENT = Gauge(
|
|
||||||
"ariadne_hermes_triage_incident",
|
|
||||||
"Hermes auto-triage incident state (1=current status, 0=other statuses)",
|
|
||||||
["jenkins_job", "build", "status"],
|
|
||||||
)
|
|
||||||
HERMES_TRIAGE_ACTION_TOTAL = Counter(
|
|
||||||
"ariadne_hermes_triage_action_total",
|
|
||||||
"Hermes auto-triage remediation actions by result",
|
|
||||||
["action", "result"],
|
|
||||||
)
|
|
||||||
HERMES_TRIAGE_LAST_SUCCESS_TS = Gauge(
|
|
||||||
"ariadne_hermes_triage_last_success_timestamp_seconds",
|
|
||||||
"Last Hermes auto-triage incident resolution timestamp",
|
|
||||||
)
|
|
||||||
HERMES_TRIAGE_DURATION_SECONDS = Gauge(
|
|
||||||
"ariadne_hermes_triage_duration_seconds",
|
|
||||||
"Duration of the latest Hermes auto-triage phase in seconds",
|
|
||||||
["phase"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_hermes_autotriage(storage: Any) -> dict[str, Any]:
|
def run_hermes_autotriage(storage: Any) -> dict[str, Any]:
|
||||||
"""Run one Hermes auto-triage tick over the allowlisted Jenkins jobs.
|
"""Run one Hermes auto-triage tick over the allowlisted Jenkins jobs.
|
||||||
|
|
||||||
@ -130,6 +108,7 @@ def _process_job(storage: Any, job: str, incidents: dict[str, dict[str, Any]]) -
|
|||||||
last_build = _fetch_last_build(job)
|
last_build = _fetch_last_build(job)
|
||||||
if last_build is None or last_build.get("building") or last_build.get("number") is None:
|
if last_build is None or last_build.get("building") or last_build.get("number") is None:
|
||||||
return {"status": "skipped"}
|
return {"status": "skipped"}
|
||||||
|
refresh_incident_gauges(job, last_build, incidents)
|
||||||
result = str(last_build.get("result") or "").upper()
|
result = str(last_build.get("result") or "").upper()
|
||||||
if result == "SUCCESS":
|
if result == "SUCCESS":
|
||||||
return _resolve_on_success(storage, job, last_build, incidents)
|
return _resolve_on_success(storage, job, last_build, incidents)
|
||||||
@ -343,16 +322,7 @@ def _record_incident(
|
|||||||
"""Append an incident event and publish its one-hot status gauge."""
|
"""Append an incident event and publish its one-hot status gauge."""
|
||||||
|
|
||||||
storage.record_event(INCIDENT_EVENT_TYPE, {**base, "status": status, "phase": phase or {}})
|
storage.record_event(INCIDENT_EVENT_TYPE, {**base, "status": status, "phase": phase or {}})
|
||||||
_set_incident_gauge(str(base["job"]), str(base["build_number"]), {status, *extra_statuses})
|
set_incident_gauge(str(base["job"]), str(base["build_number"]), {status, *extra_statuses})
|
||||||
|
|
||||||
|
|
||||||
def _set_incident_gauge(job: str, build: str, active: set[str]) -> None:
|
|
||||||
"""Set the incident gauge to 1 for active statuses and 0 for the rest."""
|
|
||||||
|
|
||||||
for status in INCIDENT_STATUSES:
|
|
||||||
HERMES_TRIAGE_INCIDENT.labels(jenkins_job=job, build=build, status=status).set(
|
|
||||||
1.0 if status in active else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _record_action(
|
def _record_action(
|
||||||
|
|||||||
95
ariadne/services/hermes_autotriage_metrics.py
Normal file
95
ariadne/services/hermes_autotriage_metrics.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
"""Prometheus metrics for Hermes auto-triage, with restart-safe rehydration.
|
||||||
|
|
||||||
|
The incident gauge is one-hot per (jenkins_job, build): the current status
|
||||||
|
holds 1 and every other status holds 0. Because gauge state lives in
|
||||||
|
process memory, each scheduler tick republishes the gauges for known
|
||||||
|
incidents so pod restarts cannot silently drop an active human-required
|
||||||
|
signal before the alert's hold window elapses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from prometheus_client import Counter, Gauge
|
||||||
|
|
||||||
|
|
||||||
|
INCIDENT_STATUSES = (
|
||||||
|
"detected",
|
||||||
|
"diagnosed",
|
||||||
|
"repairing",
|
||||||
|
"awaiting_rebuild",
|
||||||
|
"resolved",
|
||||||
|
"human_required",
|
||||||
|
"failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
HERMES_TRIAGE_INCIDENT = Gauge(
|
||||||
|
"ariadne_hermes_triage_incident",
|
||||||
|
"Hermes auto-triage incident state (1=current status, 0=other statuses)",
|
||||||
|
["jenkins_job", "build", "status"],
|
||||||
|
)
|
||||||
|
HERMES_TRIAGE_ACTION_TOTAL = Counter(
|
||||||
|
"ariadne_hermes_triage_action_total",
|
||||||
|
"Hermes auto-triage remediation actions by result",
|
||||||
|
["action", "result"],
|
||||||
|
)
|
||||||
|
HERMES_TRIAGE_LAST_SUCCESS_TS = Gauge(
|
||||||
|
"ariadne_hermes_triage_last_success_timestamp_seconds",
|
||||||
|
"Last Hermes auto-triage incident resolution timestamp",
|
||||||
|
)
|
||||||
|
HERMES_TRIAGE_DURATION_SECONDS = Gauge(
|
||||||
|
"ariadne_hermes_triage_duration_seconds",
|
||||||
|
"Duration of the latest Hermes auto-triage phase in seconds",
|
||||||
|
["phase"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_incident_gauge(job: str, build: str, active: set[str]) -> None:
|
||||||
|
"""Set the incident gauge to 1 for active statuses and 0 for the rest.
|
||||||
|
|
||||||
|
Inputs: the Jenkins job name, the build number as a string, and the
|
||||||
|
set of statuses that should read 1. Outputs: none.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for status in INCIDENT_STATUSES:
|
||||||
|
HERMES_TRIAGE_INCIDENT.labels(jenkins_job=job, build=build, status=status).set(
|
||||||
|
1 if status in active else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_incident_gauges(job: str, last_build: dict[str, Any], incidents: dict[str, dict[str, Any]]) -> None:
|
||||||
|
"""Republish incident gauges for one job from stored incident state.
|
||||||
|
|
||||||
|
Inputs: the job name, its Jenkins lastBuild summary, and the folded
|
||||||
|
latest-state-per-incident map. Outputs: none. Unresolved incidents keep
|
||||||
|
their one-hot status (a repair failure also keeps human_required); an
|
||||||
|
incident superseded by a newer successful build is zeroed so the
|
||||||
|
human-required alert clears once the job is green again. Resolved
|
||||||
|
incidents are left quiet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
number = _int_value(last_build.get("number"))
|
||||||
|
superseded = str(last_build.get("result") or "").upper() == "SUCCESS"
|
||||||
|
for incident in incidents.values():
|
||||||
|
if incident.get("job") != job:
|
||||||
|
continue
|
||||||
|
status = str(incident.get("status") or "")
|
||||||
|
if status not in INCIDENT_STATUSES or status == "resolved":
|
||||||
|
continue
|
||||||
|
build = str(incident.get("build_number"))
|
||||||
|
if superseded and _int_value(incident.get("build_number")) < number:
|
||||||
|
set_incident_gauge(job, build, set())
|
||||||
|
elif status == "failed":
|
||||||
|
set_incident_gauge(job, build, {"failed", "human_required"})
|
||||||
|
else:
|
||||||
|
set_incident_gauge(job, build, {status})
|
||||||
|
|
||||||
|
|
||||||
|
def _int_value(value: Any) -> int:
|
||||||
|
"""Coerce a value to int, defaulting to zero."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
72
tests/test_hermes_autotriage_metrics.py
Normal file
72
tests/test_hermes_autotriage_metrics.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ariadne.services import hermes_autotriage_metrics as module
|
||||||
|
|
||||||
|
|
||||||
|
JOB = "hermes-triage-demo"
|
||||||
|
|
||||||
|
|
||||||
|
def _gauge(build: str, status: str) -> float:
|
||||||
|
return module.HERMES_TRIAGE_INCIDENT.labels(
|
||||||
|
jenkins_job=JOB, build=build, status=status
|
||||||
|
)._value.get()
|
||||||
|
|
||||||
|
|
||||||
|
def _incident(build: int, status: str) -> dict:
|
||||||
|
return {
|
||||||
|
"incident_id": f"{JOB}/{build}",
|
||||||
|
"job": JOB,
|
||||||
|
"build_number": build,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_incident_gauge_is_one_hot() -> None:
|
||||||
|
module.set_incident_gauge(JOB, "90", {"diagnosed"})
|
||||||
|
assert _gauge("90", "diagnosed") == 1
|
||||||
|
for status in module.INCIDENT_STATUSES:
|
||||||
|
if status != "diagnosed":
|
||||||
|
assert _gauge("90", status) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_republishes_active_incident_after_restart() -> None:
|
||||||
|
"""A human_required incident must survive a gauge-wiping pod restart."""
|
||||||
|
|
||||||
|
incidents = {f"{JOB}/91": _incident(91, "human_required")}
|
||||||
|
last_build = {"number": 91, "result": "FAILURE"}
|
||||||
|
module.refresh_incident_gauges(JOB, last_build, incidents)
|
||||||
|
assert _gauge("91", "human_required") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_clears_incident_superseded_by_newer_green_build() -> None:
|
||||||
|
incidents = {f"{JOB}/92": _incident(92, "human_required")}
|
||||||
|
module.refresh_incident_gauges(JOB, {"number": 92, "result": "FAILURE"}, incidents)
|
||||||
|
assert _gauge("92", "human_required") == 1
|
||||||
|
module.refresh_incident_gauges(JOB, {"number": 93, "result": "SUCCESS"}, incidents)
|
||||||
|
assert _gauge("92", "human_required") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_keeps_failed_incident_alerting_with_human_required() -> None:
|
||||||
|
incidents = {f"{JOB}/94": _incident(94, "failed")}
|
||||||
|
module.refresh_incident_gauges(JOB, {"number": 94, "result": "FAILURE"}, incidents)
|
||||||
|
assert _gauge("94", "failed") == 1
|
||||||
|
assert _gauge("94", "human_required") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_skips_resolved_foreign_and_unknown_incidents() -> None:
|
||||||
|
incidents = {
|
||||||
|
f"{JOB}/95": _incident(95, "resolved"),
|
||||||
|
"other-job/9": {**_incident(9, "human_required"), "job": "other-job", "incident_id": "other-job/9"},
|
||||||
|
f"{JOB}/96": _incident(96, "not-a-status"),
|
||||||
|
}
|
||||||
|
module.refresh_incident_gauges(JOB, {"number": 97, "result": "FAILURE"}, incidents)
|
||||||
|
assert _gauge("95", "resolved") == 0
|
||||||
|
assert _gauge("96", "detected") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_same_build_green_does_not_clear() -> None:
|
||||||
|
"""A success at the same build number is not a supersession."""
|
||||||
|
|
||||||
|
incidents = {f"{JOB}/98": _incident(98, "human_required")}
|
||||||
|
module.refresh_incident_gauges(JOB, {"number": 98, "result": "SUCCESS"}, incidents)
|
||||||
|
assert _gauge("98", "human_required") == 1
|
||||||
Loading…
x
Reference in New Issue
Block a user