fix(mcp): get_anomaly keeps the events nearest the occurrence and says how many it left out (#5861)

Each source in an anomaly's `around` was cut to its oldest 40 with
nothing in the reply to say so; on a busy host the events nearest the
occurrence were the ones dropped. Each source now keeps the 40 nearest
the occurrence's start, and `around_left_out` counts the rest. Sources
return everything their bounded query read (Docker and the host agent
no longer cut at 40 and 20 themselves), so the count is honest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 15:59:16 -04:00
co-authored by Claude Opus 5.5
parent 91bb199d14
commit 622addc09e
7 changed files with 63 additions and 28 deletions
+41 -19
View File
@@ -34,7 +34,8 @@ from ._shape import db_session, iso
logger = logging.getLogger(__name__)
PER_SOURCE = 40 # events any one source contributes to one window
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
@@ -98,7 +99,7 @@ async def alert_events(db, place: Place, since: datetime, until: datetime) -> li
note = f" ({ev.note})" if ev.note else ""
out.append(event(ev.transitioned_at, "alert", f"{rule.name}: {ev.from_state} → {ev.to_state}{note}",
subject=rule.resource_name, ids={"rule_id": rule.id}))
return out[:PER_SOURCE]
return out
async def ansible_runs(db, place: Place, since: datetime, until: datetime) -> list[dict]:
@@ -119,7 +120,7 @@ async def ansible_runs(db, place: Place, since: datetime, until: datetime) -> li
out.append(event(r.started_at, "ansible_run",
f"Ansible {r.playbook_path}{check}: {r.status.value}",
until=r.finished_at, ids={"run_id": r.id}))
return out[:PER_SOURCE]
return out
def _same_host(source: str, resource: str, place: Place) -> bool:
@@ -148,7 +149,7 @@ async def other_anomalies(db, place: Place, anomaly_id: str, since: datetime,
until=span.ended_at, subject=a.resource_name, ids={"anomaly_id": a.id}))
else:
elsewhere += 1
return here[:PER_SOURCE], elsewhere
return here, elsewhere
def gaps_in(times: list[datetime], since: datetime, until: datetime) -> list[dict]:
@@ -179,9 +180,10 @@ async def steward_gaps(db, since: datetime, until: datetime) -> list[dict]:
return gaps_in(list(rows), since, until)
async def plugin_events(place: Place, since: datetime, until: datetime) -> list[dict]:
"""What enabled plugins know happened on the host (`mcp_events`), each
in its own session so one that fails can't spoil the others' reads."""
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 []
@@ -191,15 +193,16 @@ async def plugin_events(place: Place, since: datetime, until: datetime) -> list[
continue
try:
async with db_session() as db:
out.extend((await source(db, place.host_id, place.host_name, since, until))[:PER_SOURCE])
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[dict]:
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."""
(`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 []
@@ -209,18 +212,37 @@ async def resource_events(place: Place, since: datetime, until: datetime) -> lis
continue
try:
async with db_session() as db:
out.extend((await source(db, place.source, place.resource, since, until))[:PER_SOURCE])
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
async def around(db, place: Place, anomaly_id: str, since: datetime,
until: datetime) -> tuple[list[dict], int]:
"""Everything above for one window, oldest first, and the count of
anomalies elsewhere."""
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)
found = (await alert_events(db, place, since, until) + await ansible_runs(db, place, since, until)
+ anomalies + await plugin_events(place, since, until)
+ await resource_events(place, since, until) + await steward_gaps(db, since, until))
return sorted(found, key=lambda e: e["at"] or ""), elsewhere
groups = [await alert_events(db, place, since, until), await ansible_runs(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
+6 -3
View File
@@ -163,7 +163,9 @@ async def get_anomaly(anomaly_id: str, occurrences: int = 5) -> dict:
stops; for a Traefik router, its swarm service's containers on any
host), and kind "steward": a stretch Steward itself wasn't recording
(a restart or deploy), around which any reading can be wrong. From 30
minutes before to 30 minutes after; and
minutes before to 30 minutes after. Each kind keeps the 40 events
nearest the occurrence's start; `around_left_out` counts the rest
(0 when nothing was cut). And
`anomalies_elsewhere`, how many anomalies on other hosts overlapped it.
Many at once points at something shared (network, storage, Steward).
- `series`: the newest shown occurrence with time either side (as long
@@ -194,8 +196,8 @@ async def get_anomaly(anomaly_id: str, occurrences: int = 5) -> dict:
shown = []
for span in spans[:limit]:
usual = _usual_at(kind, slots, span.started_at)
near, elsewhere = await events.around(db, place, a.id, span.started_at - AROUND,
(span.ended_at or now) + AROUND)
near, elsewhere, left_out = await events.around(db, place, a.id, span.started_at - AROUND,
(span.ended_at or now) + AROUND, span.started_at)
shown.append({
"started_at": iso(span.started_at), "ended_at": iso(span.ended_at),
"ongoing": span.ended_at is None,
@@ -206,6 +208,7 @@ async def get_anomaly(anomaly_id: str, occurrences: int = 5) -> dict:
"flag_above": rnd(flag_above(usual.usual_hold, floor), 3) if floor is not None else None}
if usual else None),
"around": near,
"around_left_out": left_out,
"anomalies_elsewhere": elsewhere,
})
series = None