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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user