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>
This commit is contained in:
@@ -44,6 +44,7 @@ def test_an_anomaly_with_its_circumstances_judged_through_mcp(app):
|
||||
from steward.models.metrics import PluginMetric
|
||||
from steward.models.norms import MetricAnomaly, MetricAnomalySpan, MetricNorm
|
||||
from steward.models.users import User, UserRole
|
||||
from steward.timeline.record import from_alert, from_run, record
|
||||
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
host_name, other_host = f"an-{tag}", f"an-other-{tag}"
|
||||
@@ -98,21 +99,28 @@ def test_an_anomaly_with_its_circumstances_judged_through_mcp(app):
|
||||
created_by=user.id)
|
||||
s.add(rule)
|
||||
await s.flush()
|
||||
s.add(AlertEvent(rule_id=rule.id, from_state="ok", to_state="firing", metric_value=91.0,
|
||||
transitioned_at=new_start + timedelta(minutes=20)))
|
||||
s.add(AnsibleRun(playbook_path="maintenance/backup.yml", source_name="steward-builtin",
|
||||
fired = AlertEvent(rule_id=rule.id, from_state="ok", to_state="firing", metric_value=91.0,
|
||||
transitioned_at=new_start + timedelta(minutes=20))
|
||||
run = AnsibleRun(playbook_path="maintenance/backup.yml", source_name="steward-builtin",
|
||||
status=AnsibleRunStatus.success, started_at=new_start - timedelta(minutes=10),
|
||||
finished_at=new_start + timedelta(minutes=5), params={"check": False},
|
||||
results={"hosts": {host_name: {"ok": 3}}, "failures": []}))
|
||||
results={"hosts": {host_name: {"ok": 3}}, "failures": []})
|
||||
s.add_all([fired, run])
|
||||
await s.flush()
|
||||
# The timeline, as the alert, the run and the plugins write it.
|
||||
await from_alert(s, rule, fired)
|
||||
await from_run(s, run)
|
||||
record(s, type="docker.restarted", at=new_start + timedelta(minutes=15),
|
||||
summary=f"web on {host_name}: died (exit 137)", hosts=[host_name],
|
||||
subject=f"{host_name}/web")
|
||||
record(s, type="host.rebooted", at=new_end - timedelta(minutes=5),
|
||||
until=new_end + timedelta(minutes=1), summary=f"{host_name}: rebooted for 6m",
|
||||
hosts=[host_name], subject=host_name)
|
||||
# A container event the timeline doesn't hold, from Docker's own source.
|
||||
await s.execute(text(
|
||||
"INSERT INTO docker_events (id, host_id, container_name, event, detail, at) "
|
||||
"VALUES (:id, :h, 'web', 'die', 'exit 137', :at)"),
|
||||
{"id": str(uuid.uuid4()), "h": host.id, "at": new_start + timedelta(minutes=15)})
|
||||
await s.execute(text(
|
||||
"INSERT INTO host_downtime (id, host_id, kind, started_at, ended_at, created_at) "
|
||||
"VALUES (:id, :h, 'reboot', :s, :e, now())"),
|
||||
{"id": str(uuid.uuid4()), "h": host.id, "s": new_end - timedelta(minutes=5),
|
||||
"e": new_end + timedelta(minutes=1)})
|
||||
"VALUES (:id, :h, 'web', 'health_change', 'healthy→unhealthy', :at)"),
|
||||
{"id": str(uuid.uuid4()), "h": host.id, "at": new_start + timedelta(minutes=16)})
|
||||
|
||||
async def _go():
|
||||
await _seed()
|
||||
@@ -127,12 +135,14 @@ def test_an_anomaly_with_its_circumstances_judged_through_mcp(app):
|
||||
assert newest["held_s"] == 3600 and newest["peak_level"] == 93.0
|
||||
assert newest["usual"]["usual_hold"] == 20.0 and newest["usual"]["flag_above"] >= 25.0
|
||||
kinds = {e["kind"] for e in newest["around"]}
|
||||
assert {"alert", "ansible_run", "container", "downtime", "anomaly"} <= kinds
|
||||
assert {"alert.fired", "ansible.unchanged", "docker.restarted", "host.rebooted", "container",
|
||||
"anomaly"} <= kinds
|
||||
whats = " | ".join(e["what"] for e in newest["around"])
|
||||
assert "web die (exit 137)" in whats and "maintenance/backup.yml" in whats
|
||||
assert "died (exit 137)" in whats and "maintenance/backup.yml" in whats
|
||||
assert "web health_change (healthy→unhealthy)" in whats
|
||||
assert any(e["kind"] == "anomaly" and e["ids"]["anomaly_id"] == ids["mem"] for e in newest["around"])
|
||||
assert newest["anomalies_elsewhere"] >= 1
|
||||
assert not [e for e in oldest["around"] if e["kind"] in ("container", "ansible_run")]
|
||||
assert not [e for e in oldest["around"] if e["kind"] in ("container", "ansible.unchanged")]
|
||||
points = got["series"]["points"]
|
||||
assert max(p[1] for p in points) == 90.0 and min(p[1] for p in points) == 18.0
|
||||
assert any(p[2] == 20.0 for p in points)
|
||||
|
||||
Reference in New Issue
Block a user