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:
@@ -403,7 +403,7 @@ async def mcp_events(db, host_id, host_name, since, until) -> list[dict]:
|
|||||||
for r in await _jobs_on(db, host_id, since, until)]
|
for r in await _jobs_on(db, host_id, since, until)]
|
||||||
```
|
```
|
||||||
|
|
||||||
- Return what happened between `since` and `until`, oldest first: something that lasted gives `until`. Keep it to tens of rows; the caller keeps the first 40.
|
- Return what happened between `since` and `until`, oldest first: something that lasted gives `until`. Bound your query (a few hundred rows); Steward keeps the 40 nearest the occurrence's start and tells the caller how many it left out (`around_left_out`).
|
||||||
- It is called only while the plugin is enabled, with its own session. If it raises, the anomaly is still returned without the plugin's events. The host agent (maintenance jobs, reboots) and Docker (container starts, deaths, OOM kills) are the examples to read.
|
- It is called only while the plugin is enabled, with its own session. If it raises, the anomaly is still returned without the plugin's events. The host agent (maintenance jobs, reboots) and Docker (container starts, deaths, OOM kills) are the examples to read.
|
||||||
|
|
||||||
A resource with no host (a Traefik router, say) has no host to read events by. A plugin that knows what happened to one of its own resources exports `mcp_resource_events(db, source, resource, since, until)` instead, same return shape, and returns `[]` for a source that isn't its own. Traefik's is the example: it gives a router's swarm service's container events, on whichever hosts they ran.
|
A resource with no host (a Traefik router, say) has no host to read events by. A plugin that knows what happened to one of its own resources exports `mcp_resource_events(db, source, resource, since, until)` instead, same return shape, and returns `[]` for a source that isn't its own. Traefik's is the example: it gives a router's swarm service's container events, on whichever hosts they ran.
|
||||||
|
|||||||
@@ -758,7 +758,7 @@ async def mcp_events(db, host_id: str, host_name: str, since, until) -> list[dic
|
|||||||
from steward.mcp.tools._events import event
|
from steward.mcp.tools._events import event
|
||||||
rows = (await db.execute(select(DockerEvent).where(
|
rows = (await db.execute(select(DockerEvent).where(
|
||||||
DockerEvent.host_id == host_id, DockerEvent.at >= since, DockerEvent.at <= until)
|
DockerEvent.host_id == host_id, DockerEvent.at >= since, DockerEvent.at <= until)
|
||||||
.order_by(DockerEvent.at).limit(40))).scalars()
|
.order_by(DockerEvent.at).limit(500))).scalars()
|
||||||
return [event(e.at, "container", f"{e.container_name} {e.event}" + (f" ({e.detail})" if e.detail else ""),
|
return [event(e.at, "container", f"{e.container_name} {e.event}" + (f" ({e.detail})" if e.detail else ""),
|
||||||
subject=f"{host_name}/{e.container_name}") for e in rows]
|
subject=f"{host_name}/{e.container_name}") for e in rows]
|
||||||
|
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ async def mcp_events(db, host_id: str, host_name: str, since, until) -> list[dic
|
|||||||
jobs = (await db.execute(select(HostMaintenanceJob).where(
|
jobs = (await db.execute(select(HostMaintenanceJob).where(
|
||||||
HostMaintenanceJob.host_id == host_id, HostMaintenanceJob.created_at <= until,
|
HostMaintenanceJob.host_id == host_id, HostMaintenanceJob.created_at <= until,
|
||||||
(HostMaintenanceJob.finished_at.is_(None)) | (HostMaintenanceJob.finished_at >= since))
|
(HostMaintenanceJob.finished_at.is_(None)) | (HostMaintenanceJob.finished_at >= since))
|
||||||
.order_by(HostMaintenanceJob.created_at).limit(20))).scalars()
|
.order_by(HostMaintenanceJob.created_at).limit(200))).scalars()
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
out.append(event(job.created_at, "maintenance", f"Maintenance: {maintenance.summary(job)}",
|
out.append(event(job.created_at, "maintenance", f"Maintenance: {maintenance.summary(job)}",
|
||||||
until=job.finished_at, ids={"job_id": job.id}))
|
until=job.finished_at, ids={"job_id": job.id}))
|
||||||
@@ -311,7 +311,7 @@ async def mcp_events(db, host_id: str, host_name: str, since, until) -> list[dic
|
|||||||
rows = (await db.execute(select(HostDowntime).where(
|
rows = (await db.execute(select(HostDowntime).where(
|
||||||
HostDowntime.host_id == host_id, HostDowntime.started_at <= until,
|
HostDowntime.host_id == host_id, HostDowntime.started_at <= until,
|
||||||
(HostDowntime.ended_at.is_(None)) | (HostDowntime.ended_at >= since))
|
(HostDowntime.ended_at.is_(None)) | (HostDowntime.ended_at >= since))
|
||||||
.order_by(HostDowntime.started_at).limit(20))).scalars()
|
.order_by(HostDowntime.started_at).limit(200))).scalars()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
seen = downtime.describe(row, now)
|
seen = downtime.describe(row, now)
|
||||||
out.append(event(row.started_at, "downtime",
|
out.append(event(row.started_at, "downtime",
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ async def service_events(db, router: str, since, until) -> list[dict]:
|
|||||||
host: a router anomaly has no host of its own to read events by (#5862).
|
host: a router anomaly has no host of its own to read events by (#5862).
|
||||||
[] without the Docker plugin, or when no single service matches."""
|
[] without the Docker plugin, or when no single service matches."""
|
||||||
from steward.core.plugin_manager import loaded_plugin_submodule
|
from steward.core.plugin_manager import loaded_plugin_submodule
|
||||||
from steward.mcp.tools._events import PER_SOURCE, event
|
from steward.mcp.tools._events import event
|
||||||
from steward.models.hosts import Host
|
from steward.models.hosts import Host
|
||||||
models = loaded_plugin_submodule("docker", "models")
|
models = loaded_plugin_submodule("docker", "models")
|
||||||
if models is None:
|
if models is None:
|
||||||
@@ -335,7 +335,7 @@ async def service_events(db, router: str, since, until) -> list[dict]:
|
|||||||
rows = (await db.execute(
|
rows = (await db.execute(
|
||||||
select(e, Host.name).join(Host, Host.id == e.host_id)
|
select(e, Host.name).join(Host, Host.id == e.host_id)
|
||||||
.where(e.container_name.startswith(f"{service}.", autoescape=True), e.at >= since, e.at <= until)
|
.where(e.container_name.startswith(f"{service}.", autoescape=True), e.at >= since, e.at <= until)
|
||||||
.order_by(e.at).limit(PER_SOURCE))).all()
|
.order_by(e.at).limit(500))).all()
|
||||||
return [event(ev.at, "container", f"{ev.container_name} {ev.event}" + (f" ({ev.detail})" if ev.detail else ""),
|
return [event(ev.at, "container", f"{ev.container_name} {ev.event}" + (f" ({ev.detail})" if ev.detail else ""),
|
||||||
subject=f"{host}/{ev.container_name}") for ev, host in rows]
|
subject=f"{host}/{ev.container_name}") for ev, host in rows]
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ from ._shape import db_session, iso
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# 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.
|
# longer break between two samples is a stretch it wasn't recording.
|
||||||
STEWARD_GAP_S = 90.0
|
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 ""
|
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}",
|
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}))
|
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]:
|
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",
|
out.append(event(r.started_at, "ansible_run",
|
||||||
f"Ansible {r.playbook_path}{check}: {r.status.value}",
|
f"Ansible {r.playbook_path}{check}: {r.status.value}",
|
||||||
until=r.finished_at, ids={"run_id": r.id}))
|
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:
|
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}))
|
until=span.ended_at, subject=a.resource_name, ids={"anomaly_id": a.id}))
|
||||||
else:
|
else:
|
||||||
elsewhere += 1
|
elsewhere += 1
|
||||||
return here[:PER_SOURCE], elsewhere
|
return here, elsewhere
|
||||||
|
|
||||||
|
|
||||||
def gaps_in(times: list[datetime], since: datetime, until: datetime) -> list[dict]:
|
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)
|
return gaps_in(list(rows), since, until)
|
||||||
|
|
||||||
|
|
||||||
async def plugin_events(place: Place, since: datetime, until: datetime) -> list[dict]:
|
async def plugin_events(place: Place, since: datetime, until: datetime) -> list[list[dict]]:
|
||||||
"""What enabled plugins know happened on the host (`mcp_events`), each
|
"""What enabled plugins know happened on the host (`mcp_events`), one
|
||||||
in its own session so one that fails can't spoil the others' reads."""
|
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)
|
server = getattr(mcp_app(), "mcp_server", None)
|
||||||
if server is None or not place.host_id:
|
if server is None or not place.host_id:
|
||||||
return []
|
return []
|
||||||
@@ -191,15 +193,16 @@ async def plugin_events(place: Place, since: datetime, until: datetime) -> list[
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
async with db_session() as db:
|
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:
|
except Exception:
|
||||||
logger.exception("MCP events source of plugin %r failed", plugin)
|
logger.exception("MCP events source of plugin %r failed", plugin)
|
||||||
return out
|
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
|
"""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)
|
server = getattr(mcp_app(), "mcp_server", None)
|
||||||
if server is None:
|
if server is None:
|
||||||
return []
|
return []
|
||||||
@@ -209,18 +212,37 @@ async def resource_events(place: Place, since: datetime, until: datetime) -> lis
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
async with db_session() as db:
|
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:
|
except Exception:
|
||||||
logger.exception("MCP resource events source of plugin %r failed", plugin)
|
logger.exception("MCP resource events source of plugin %r failed", plugin)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def around(db, place: Place, anomaly_id: str, since: datetime,
|
def _at(e: dict) -> datetime | None:
|
||||||
until: datetime) -> tuple[list[dict], int]:
|
return datetime.fromisoformat(e["at"].replace("Z", "+00:00")) if e.get("at") else None
|
||||||
"""Everything above for one window, oldest first, and the count of
|
|
||||||
anomalies elsewhere."""
|
|
||||||
|
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)
|
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)
|
groups = [await alert_events(db, place, since, until), await ansible_runs(db, place, since, until),
|
||||||
+ anomalies + await plugin_events(place, since, until)
|
anomalies, *await plugin_events(place, since, until),
|
||||||
+ await resource_events(place, since, until) + await steward_gaps(db, since, until))
|
*await resource_events(place, since, until), await steward_gaps(db, since, until)]
|
||||||
return sorted(found, key=lambda e: e["at"] or ""), elsewhere
|
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
|
stops; for a Traefik router, its swarm service's containers on any
|
||||||
host), and kind "steward": a stretch Steward itself wasn't recording
|
host), and kind "steward": a stretch Steward itself wasn't recording
|
||||||
(a restart or deploy), around which any reading can be wrong. From 30
|
(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.
|
`anomalies_elsewhere`, how many anomalies on other hosts overlapped it.
|
||||||
Many at once points at something shared (network, storage, Steward).
|
Many at once points at something shared (network, storage, Steward).
|
||||||
- `series`: the newest shown occurrence with time either side (as long
|
- `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 = []
|
shown = []
|
||||||
for span in spans[:limit]:
|
for span in spans[:limit]:
|
||||||
usual = _usual_at(kind, slots, span.started_at)
|
usual = _usual_at(kind, slots, span.started_at)
|
||||||
near, elsewhere = await events.around(db, place, a.id, span.started_at - AROUND,
|
near, elsewhere, left_out = await events.around(db, place, a.id, span.started_at - AROUND,
|
||||||
(span.ended_at or now) + AROUND)
|
(span.ended_at or now) + AROUND, span.started_at)
|
||||||
shown.append({
|
shown.append({
|
||||||
"started_at": iso(span.started_at), "ended_at": iso(span.ended_at),
|
"started_at": iso(span.started_at), "ended_at": iso(span.ended_at),
|
||||||
"ongoing": span.ended_at is None,
|
"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}
|
"flag_above": rnd(flag_above(usual.usual_hold, floor), 3) if floor is not None else None}
|
||||||
if usual else None),
|
if usual else None),
|
||||||
"around": near,
|
"around": near,
|
||||||
|
"around_left_out": left_out,
|
||||||
"anomalies_elsewhere": elsewhere,
|
"anomalies_elsewhere": elsewhere,
|
||||||
})
|
})
|
||||||
series = None
|
series = None
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ def test_a_plugin_adds_a_hostless_resources_events_through_its_hook(app):
|
|||||||
mcp.resource_event_sources.pop("events_test", None)
|
mcp.resource_event_sources.pop("events_test", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_busy_window_keeps_the_events_nearest_the_occurrence_and_counts_the_rest():
|
||||||
|
"""#5861: 50 container events over an hour; the occurrence starts at :45."""
|
||||||
|
evs = [_events.event(NOW + timedelta(minutes=m), "container", f"c{m} start") for m in range(50)]
|
||||||
|
kept, cut = _events.nearest(evs, NOW + timedelta(minutes=45))
|
||||||
|
assert len(kept) == 40 and cut == 10
|
||||||
|
assert {e["what"] for e in kept} >= {"c45 start", "c49 start", "c20 start"}
|
||||||
|
assert "c0 start" not in {e["what"] for e in kept}
|
||||||
|
assert _events.nearest(evs[:5], NOW) == (evs[:5], 0)
|
||||||
|
|
||||||
|
|
||||||
def test_steward_not_recording_is_an_event_beside_every_anomaly():
|
def test_steward_not_recording_is_an_event_beside_every_anomaly():
|
||||||
"""A Steward restart between two of its once-a-minute samples (#5862)."""
|
"""A Steward restart between two of its once-a-minute samples (#5862)."""
|
||||||
ticks = [NOW + timedelta(minutes=m) for m in (0, 1, 2)] + [NOW + timedelta(minutes=4, seconds=10)]
|
ticks = [NOW + timedelta(minutes=m) for m in (0, 1, 2)] + [NOW + timedelta(minutes=4, seconds=10)]
|
||||||
|
|||||||
Reference in New Issue
Block a user