fix(alerts): one silent agent is one alert and one finding (#5721)
VDNT-Docker03's silent agent read as three overview findings (agent offline,
agent stale, the disk rule's no-data) and every drop emailed the disk rule's
silence and return on top of agent offline.
- The host agent registers its heartbeat (seconds_since_seen) and the
sources it carries (host_agent, docker). While a host's agent is past its
offline window, its other rules' no_data is recorded "while its agent is
offline" and not emailed, nor is their quiet return; a return past a
threshold still fires.
- The overview folds those rules under the host's agent-offline finding
("also silent with it: ..."), and a stale agent whose agent-offline alert
is active is that alert's finding, not a second one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ from datetime import timedelta
|
||||
from sqlalchemy import select
|
||||
|
||||
from steward.alerts import service as alert_svc
|
||||
from steward.core import alerts
|
||||
from steward.core import anomalies as anom
|
||||
from steward.hosts import service as host_svc
|
||||
from steward.mcp._context import mcp_app
|
||||
@@ -70,8 +71,37 @@ def monitor_findings(monitors, states: dict, host_names: dict, now,
|
||||
return out
|
||||
|
||||
|
||||
def alert_findings(active) -> list[dict]:
|
||||
def fold_agent_silence(active, agent: dict) -> tuple[list, dict[str, list[str]], set[str]]:
|
||||
"""One silent agent is one finding (#5721). Returns (the active alerts left
|
||||
to list, {host: names of its rules silent with its agent}, the hosts
|
||||
whose "agent offline" alert is active and so speaks for the agent).
|
||||
|
||||
A rule the agent feeds that has only gone quiet, on a host whose agent is
|
||||
stale, is folded under that host; one firing or pending stays listed,
|
||||
since what it measured before going quiet is news of its own."""
|
||||
offline = {name for name, a in agent.items() if a and not a.get("fresh")}
|
||||
kept, silent, speaking = [], {}, set()
|
||||
for state, rule in active:
|
||||
if alerts.is_heartbeat(rule):
|
||||
speaking.add(rule.resource_name)
|
||||
kept.append((state, rule))
|
||||
continue
|
||||
host = alerts.host_behind(rule)
|
||||
if host in offline and state.no_data_since is not None \
|
||||
and state.state.value not in ("firing", "pending", "acknowledged"):
|
||||
silent.setdefault(host, []).append(rule.name)
|
||||
continue
|
||||
kept.append((state, rule))
|
||||
return kept, silent, speaking
|
||||
|
||||
|
||||
def _also_silent(names: list[str] | None) -> str:
|
||||
return f"; also silent with it: {', '.join(sorted(names))}" if names else ""
|
||||
|
||||
|
||||
def alert_findings(active, silent: dict[str, list[str]] | None = None) -> list[dict]:
|
||||
out = []
|
||||
silent = silent or {}
|
||||
for state, rule in active:
|
||||
s = state.state.value
|
||||
if s == "firing":
|
||||
@@ -84,6 +114,8 @@ def alert_findings(active) -> list[dict]:
|
||||
s, sev, what = "no_data", "warning", "has stopped reporting"
|
||||
if state.no_data_since is not None and s != "no_data":
|
||||
what += f" and has stopped reporting (no data since {iso(state.no_data_since)})"
|
||||
if alerts.is_heartbeat(rule):
|
||||
what += _also_silent(silent.get(rule.resource_name))
|
||||
out.append(finding(
|
||||
sev, f"alert_{s}", rule.name,
|
||||
f"{rule.metric_name} {rule.operator.value} {rule.threshold:g} on "
|
||||
@@ -98,18 +130,26 @@ _AGENT_CHECKS = (("cpu_pct", "cpu", "CPU"), ("mem_used_pct", "mem", "memory"),
|
||||
("disk_used_pct_worst", "disk", "fullest disk"))
|
||||
|
||||
|
||||
def agent_findings(hosts, agent: dict, thresholds: dict) -> list[dict]:
|
||||
def agent_findings(hosts, agent: dict, thresholds: dict,
|
||||
silent: dict[str, list[str]] | None = None,
|
||||
speaking: set[str] | frozenset = frozenset()) -> list[dict]:
|
||||
"""Hosts past a CPU, memory or disk cutoff, and agents that stopped
|
||||
reporting. A stale agent whose "agent offline" alert is active (`speaking`)
|
||||
is that alert's finding, not a second one (#5721)."""
|
||||
out = []
|
||||
silent = silent or {}
|
||||
for h in hosts:
|
||||
a = agent.get(h.name)
|
||||
if not a:
|
||||
continue
|
||||
ids = {"host_id": h.id}
|
||||
if not a.get("fresh"):
|
||||
out.append(finding(
|
||||
"warning", "agent_stale", h.name,
|
||||
"host agent has stopped reporting; its metrics are out of date",
|
||||
since=a.get("last_at"), ids=ids, next=f'get_host(host="{h.id}")'))
|
||||
if h.name not in speaking:
|
||||
out.append(finding(
|
||||
"warning", "agent_stale", h.name,
|
||||
"host agent has stopped reporting; its metrics are out of date"
|
||||
+ _also_silent(silent.get(h.name)),
|
||||
since=a.get("last_at"), ids=ids, next=f'get_host(host="{h.id}")'))
|
||||
continue # stale values would only add noise
|
||||
# CPU and memory within the host's own normal for the hour aren't
|
||||
# findings (#5742); the fullest disk keeps its cutoff.
|
||||
@@ -216,9 +256,10 @@ async def get_overview() -> dict:
|
||||
to_review = await anom.to_review_count(db)
|
||||
|
||||
thresholds = app.config.get("THRESHOLDS") or {}
|
||||
listed, silent, speaking = fold_agent_silence(active, agent)
|
||||
findings = (monitor_findings(monitors, states, host_names, now, thresholds.get("tls_days"))
|
||||
+ alert_findings(active)
|
||||
+ agent_findings(hosts, agent, thresholds)
|
||||
+ alert_findings(listed, silent)
|
||||
+ agent_findings(hosts, agent, thresholds, silent, speaking)
|
||||
+ run_findings(runs)
|
||||
+ anomaly_findings(open_anomalies))
|
||||
plugin_found, unavailable = await _plugin_findings(getattr(app, "mcp_server", None))
|
||||
|
||||
Reference in New Issue
Block a user