Files
FabledSteward/steward/mcp/tools/_events.py
T
bvandeusenandClaude Opus 5.5 2b97c36fd4
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m52s
CI / publish (push) Successful in 1m12s
feat(timeline): maintenance, reboots, deploys, crash-loops and reviewed anomalies on the timeline (#6349)
The host agent writes each maintenance job once it ends, and each stretch a
host was away (a reboot, a crash, lost contact, the agent restarting), kept up
to date until it is back. Docker judges each snapshot: a unit on a new image,
or a container recreated, is a deploy; deaths past the Thresholds crash-loop
count are one crash-loop entry that later deaths extend; single restarts are
recorded but hidden. An anomaly has one entry whose type follows its review:
only those judged real are shown by default.

A source whose record changes keeps one entry for it (record_or_update).
Plugins register the records their entries point to, so an aged-out job or
downtime reads "Details no longer kept". The first fill runs once per source
(timeline.filled), the host agent's jobs and downtime included.

get_anomaly's "what else was going on" reads the timeline for the host or
resource instead of querying alerts, runs, maintenance, downtime and container
deaths itself; Docker's own source keeps only starts, stops and health
changes. A new kind, Observed, is for what Steward saw happen with no rule or
person behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:21:44 -04:00

233 lines
11 KiB
Python

"""What else was going on: events on a host, or one resource, in a window
(#5837), so an anomaly comes with its circumstances.
Core reads the timeline (alert episodes, Ansible runs, and what plugins
wrote there: maintenance, downtime, deploys, restarts), other metric
anomalies, and Steward's own gaps in recording. A plugin adds what the
timeline doesn't hold through `mcp_events(db, host_id, host_name, since,
until)` in its __init__.py (Docker its stops and health changes, syslog its
warnings), and for a resource with no host through
`mcp_resource_events(db, source, resource, since, until)` (Traefik: the
containers of the swarm service behind a router, #5862); each returns a list
of `event(...)`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from sqlalchemy import and_, func, or_, select
from steward.core.alerts import host_of_resource
from steward.core.formatting import human_duration
from steward.mcp._context import mcp_app
from steward.models.hosts import Host
from steward.models.metrics import PluginMetric
from steward.models.monitors import Monitor
from steward.models.norms import MetricAnomaly, MetricAnomalySpan
from ._shape import db_session, iso
logger = logging.getLogger(__name__)
PER_SOURCE = 40 # events any one source contributes to one window: the
# nearest the occurrence's start, the rest counted (#5861)
# Steward samples its own health once a minute (core/self_monitor.py); a
# longer break between two samples is a stretch it wasn't recording.
STEWARD_GAP_S = 90.0
def event(at: datetime | None, kind: str, what: str, *, until: datetime | None = None,
subject: str | None = None, ids: dict | None = None) -> dict:
"""One thing that happened. `until` for something that lasted (a run, a
reboot); `subject` names what it happened to when that isn't the host."""
return {"at": iso(at), "until": iso(until), "kind": kind, "what": what,
"subject": subject, "ids": ids or {}}
@dataclass(frozen=True)
class Place:
"""Where a metric's resource lives: its host, when it has one."""
source: str
resource: str
host_id: str | None = None
host_name: str | None = None
async def place_of(db, source: str, resource: str) -> Place:
"""The host behind a resource: an agent-fed series names it ("nas:/",
"nas/web"), a monitor may be pinned to one; a Traefik router has none."""
name = host_of_resource(source, resource)
host = None
if name:
host = (await db.execute(select(Host).where(Host.name == name).limit(1))).scalar_one_or_none()
else:
monitor = (await db.execute(select(Monitor).where(
Monitor.type == source, Monitor.name == resource).limit(1))).scalar_one_or_none()
if monitor is not None and monitor.host_id:
host = await db.get(Host, monitor.host_id)
return Place(source, resource, host.id if host else None, host.name if host else None)
def _overlaps(start, end):
"""A row lasting from `start` to `end` (None: still going) overlaps the window."""
def clause(since, until):
return and_(start <= until, or_(end.is_(None), end >= since))
return clause
async def timeline_events(db, place: Place, since: datetime, until: datetime) -> list[dict]:
"""What the timeline holds about the host, or the resource itself, while
the window was open: alert episodes, playbook runs, maintenance, reboots
and lost contact, deploys, restarts and crash-loops, settings changes.
Hidden types are included: a dry run or a single restart can be what
explains a reading. Anomalies come from other_anomalies, by occurrence."""
from steward.models.timeline import TimelineEntry
about = [TimelineEntry.subject == place.resource]
if place.host_name:
about.append(TimelineEntry.hosts.contains([place.host_name]))
rows = (await db.execute(
select(TimelineEntry)
.where(or_(*about), _overlaps(TimelineEntry.at, func.coalesce(TimelineEntry.until, TimelineEntry.at))
(since, until), or_(TimelineEntry.ref_type.is_(None), TimelineEntry.ref_type != "metric_anomaly"))
.order_by(TimelineEntry.at).limit(500))).scalars()
return [event(e.at, e.type, e.summary, until=e.until, subject=e.subject,
ids={e.ref_type: e.ref_id} if e.ref_type else None) for e in rows]
def _same_host(source: str, resource: str, place: Place) -> bool:
if (source, resource) == (place.source, place.resource):
return True
return bool(place.host_name) and host_of_resource(source, resource) == place.host_name
async def other_anomalies(db, place: Place, anomaly_id: str, since: datetime,
until: datetime) -> tuple[list[dict], int]:
"""Other anomalies going on in the window: those on the same host as
events, and how many there were anywhere else (many at once points at
something shared: the network, storage, Steward itself)."""
rows = (await db.execute(
select(MetricAnomalySpan, MetricAnomaly)
.join(MetricAnomaly, MetricAnomaly.id == MetricAnomalySpan.anomaly_id)
.where(MetricAnomaly.id != anomaly_id,
_overlaps(MetricAnomalySpan.started_at, MetricAnomalySpan.ended_at)(since, until))
.order_by(MetricAnomalySpan.started_at).limit(500))).all()
here, elsewhere = [], 0
for span, a in rows:
if _same_host(a.source_module, a.resource_name, place):
here.append(event(span.started_at, "anomaly",
f"{a.resource_name} {a.metric_name} anomaly (peak {round(span.peak_level, 3)}, "
f"usual {round(a.usual_hold, 3)}; {a.review})",
until=span.ended_at, subject=a.resource_name, ids={"anomaly_id": a.id}))
else:
elsewhere += 1
return here, elsewhere
def gaps_in(times: list[datetime], since: datetime, until: datetime,
restarts: list[tuple[datetime, float]] = ()) -> list[dict]:
"""Each restart (its first sample's time and how long since the last
one) and each other break longer than STEWARD_GAP_S between consecutive
samples that overlaps the window, as a "steward" event lasting the break.
A restart is shown however short: a quick redeploy never breaks the
minute, and still restarts every count Steward keeps."""
out = []
for at, secs in restarts:
if at >= since and at - timedelta(seconds=secs) <= until:
out.append(event(at - timedelta(seconds=secs), "steward",
f"Steward restarted, not recording for {human_duration(secs)}: "
"readings either side of it can be wrong", until=at))
restarted = {at for at, _ in restarts}
for before, after in zip(times, times[1:]):
secs = (after - before).total_seconds()
if secs > STEWARD_GAP_S and after >= since and before <= until and after not in restarted:
out.append(event(before, "steward",
f"Steward wasn't recording for {human_duration(secs)} (a restart, a deploy or a "
"stall): readings either side of a gap can be wrong", until=after))
return out
async def steward_gaps(db, since: datetime, until: datetime) -> list[dict]:
"""When Steward itself wasn't recording, read from its own once-a-minute
health sample. Shown beside every anomaly, placed or not: a restart
distorts whatever Steward measures, a Traefik counter as much as a host's
silence (#5862). Raw samples only, so a window older than their
retention shows none."""
rows = (await db.execute(
select(PluginMetric.metric_name, PluginMetric.recorded_at, PluginMetric.value).where(
PluginMetric.source_module == "steward", PluginMetric.resource_name == "process",
PluginMetric.metric_name.in_(("db_roundtrip_ms", "restart_gap_secs")),
PluginMetric.recorded_at >= since - timedelta(hours=1), PluginMetric.recorded_at <= until)
.order_by(PluginMetric.recorded_at))).all()
return gaps_in([at for name, at, _ in rows if name == "db_roundtrip_ms"], since, until,
[(at, value) for name, at, value in rows if name == "restart_gap_secs"])
async def plugin_events(place: Place, since: datetime, until: datetime) -> list[list[dict]]:
"""What enabled plugins know happened on the host (`mcp_events`), one
list per plugin, each read in its own session so one that fails can't
spoil the others' reads."""
server = getattr(mcp_app(), "mcp_server", None)
if server is None or not place.host_id:
return []
out = []
for plugin, source in sorted((getattr(server, "event_sources", None) or {}).items()):
if not server.plugin_enabled(plugin):
continue
try:
async with db_session() as db:
out.append(await source(db, place.host_id, place.host_name, since, until))
except Exception:
logger.exception("MCP events source of plugin %r failed", plugin)
return out
async def resource_events(place: Place, since: datetime, until: datetime) -> list[list[dict]]:
"""What enabled plugins know happened to the resource itself
(`mcp_resource_events`), for one with no host to read events by: one
list per plugin."""
server = getattr(mcp_app(), "mcp_server", None)
if server is None:
return []
out = []
for plugin, source in sorted((getattr(server, "resource_event_sources", None) or {}).items()):
if not server.plugin_enabled(plugin):
continue
try:
async with db_session() as db:
out.append(await source(db, place.source, place.resource, since, until))
except Exception:
logger.exception("MCP resource events source of plugin %r failed", plugin)
return out
def _at(e: dict) -> datetime | None:
return datetime.fromisoformat(e["at"].replace("Z", "+00:00")) if e.get("at") else None
def nearest(events: list[dict], focus: datetime, keep: int = PER_SOURCE) -> tuple[list[dict], int]:
"""The `keep` events nearest `focus` (an occurrence's start), and how
many were left out: a busy host's window is cut where it matters least."""
if len(events) <= keep:
return events, 0
by_distance = sorted(events, key=lambda e: abs((_at(e) - focus).total_seconds()) if _at(e) else float("inf"))
return by_distance[:keep], len(events) - keep
async def around(db, place: Place, anomaly_id: str, since: datetime, until: datetime,
focus: datetime) -> tuple[list[dict], int, int]:
"""Everything above for one window, oldest first; the count of anomalies
elsewhere; and how many events were left out, each source keeping its
PER_SOURCE nearest `focus`."""
anomalies, elsewhere = await other_anomalies(db, place, anomaly_id, since, until)
groups = [await timeline_events(db, place, since, until), anomalies,
*await plugin_events(place, since, until),
*await resource_events(place, since, until), await steward_gaps(db, since, until)]
found, left_out = [], 0
for group in groups:
kept, cut = nearest(group, focus)
found += kept
left_out += cut
return sorted(found, key=lambda e: e["at"] or ""), elsewhere, left_out