Files
FabledSteward/steward/core/reports.py
T
bvandeusenandClaude Opus 5.5 5569f9934e
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m24s
CI / unit (push) Successful in 1m37s
CI / publish (push) Skipped
feat(anomalies): a metric out of its normal is an anomaly to review (#5740)
#5740 "A metric that departs from its normal is recorded as an anomaly to
review, and is findable after it ends", milestone 513.

- Every minute each learned key's last 15 minutes is judged against its
  hour's normal: above the usual hold x1.25 and by the metric's floor opens
  an occurrence, which ends when the level comes back.
- metric_anomalies holds one row per item to review; a repeat while it
  still waits joins it as an occurrence (metric_anomaly_spans).
- An ended row nobody reviews within anomalies.review_days (14, Settings ->
  Thresholds) lapses; an ongoing one never does.
- Learning skips every hour an occurrence covers unless its anomaly was
  marked normal: no normal is learned without review.
- Alerts -> Anomalies lists those to review first, then the reviewed and
  lapsed under day dividers; marking is operator/admin and audited.
- The Overview widget and get_overview warn of open ones and count those
  to review; the host page shows its own; the weekly report lists the
  week's and the lapsed; MCP list_anomalies and review_anomaly.
- Reviewed and lapsed rows are pruned at data.retention_days, and kept
  in backups like alert history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:41:57 -04:00

219 lines
8.9 KiB
Python

"""steward/core/reports.py
Weekly digest report: uptime, active alerts, alert events, top metrics.
Called by the scheduler (hourly check) or triggered manually from Settings.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, case, func, select
from steward.alerts import service as alert_svc
from steward.core.formatting import zone
from steward.core.mail import recipients, report_email
from steward.core.settings import get_setting
from steward.models.alerts import AlertEvent
from steward.models.metrics import PluginMetric
from steward.models.monitors import Monitor, MonitorResult
logger = logging.getLogger(__name__)
async def build_report_data(app) -> dict:
"""Collect all data for the weekly digest."""
now = datetime.now(timezone.utc)
cutoff_7d = now - timedelta(days=7)
cutoff_24h = now - timedelta(hours=24)
async with app.db_sessionmaker() as db:
# ── Uptime summary (7d) — per enabled monitor of any type ─────────────
monitors = (await db.execute(
select(Monitor).where(Monitor.enabled.is_(True)).order_by(Monitor.name)
)).scalars().all()
uptime_rows = await db.execute(
select(
MonitorResult.monitor_id,
func.count(MonitorResult.id).label("total"),
func.sum(case((MonitorResult.is_up.is_(True), 1), else_=0)).label("up"),
)
.where(MonitorResult.checked_at >= cutoff_7d)
.group_by(MonitorResult.monitor_id)
)
uptime_map: dict[str, float | None] = {}
for row in uptime_rows:
pct = round(float(row.up) / float(row.total) * 100, 2) if row.total else None
uptime_map[row.monitor_id] = pct
uptime_summary = [
{"name": m.name, "pct_7d": uptime_map.get(m.id)} for m in monitors
]
# ── Active alerts ─────────────────────────────────────────────────────
active_rows = await alert_svc.active_alerts(db)
active_alerts = [
{
"state": alert_svc.alert_label(state),
"name": rule.name,
"resource": rule.resource_name,
"condition": alert_svc.rule_condition(rule),
}
for state, rule in active_rows
]
# ── Alert event counts (7d) ───────────────────────────────────────────
events_result = await db.execute(
select(
func.count(case((AlertEvent.to_state == "firing", 1), else_=None)).label("fired"),
func.count(case((AlertEvent.to_state == "resolved", 1), else_=None)).label("resolved"),
)
.where(AlertEvent.transitioned_at >= cutoff_7d)
)
event_row = events_result.one()
alert_events = {
"fired": int(event_row.fired or 0),
"resolved": int(event_row.resolved or 0),
}
# ── Top Traefik routers by avg request_rate (last 24h) ────────────────
top_routers_result = await db.execute(
select(
PluginMetric.resource_name,
func.avg(PluginMetric.value).label("avg_rate"),
)
.where(
and_(
PluginMetric.source_module == "traefik",
PluginMetric.metric_name == "request_rate",
PluginMetric.recorded_at >= cutoff_24h,
)
)
.group_by(PluginMetric.resource_name)
.order_by(func.avg(PluginMetric.value).desc())
.limit(10)
)
top_routers = [
{"name": row.resource_name, "avg_rate": float(row.avg_rate)}
for row in top_routers_result
]
# ── Monitors currently down (latest result is_up = false) ─────────────
from steward.core.status import _last_n_results
enabled = (await db.execute(
select(Monitor.id, Monitor.name).where(Monitor.enabled.is_(True))
)).all()
latest = await _last_n_results(db, [m.id for m in enabled], n=1)
hosts_down = sorted(name for mid, name in enabled
if latest.get(mid) and not latest[mid][-1].is_up)
# ── Metric anomalies this week, and those that lapsed (#5740) ─────────
from steward.core import anomalies as anom
week = await anom.week_summary(db, cutoff_7d)
anomalies = {
"seen": [_anomaly(a) for a in week["seen"]],
"lapsed": [_anomaly(a) for a in week["lapsed"]],
"to_review": await anom.to_review_count(db),
}
return {
"generated_at": now,
"uptime_summary": uptime_summary,
"active_alerts": active_alerts,
"alert_events": alert_events,
"top_routers": top_routers,
"hosts_down": hosts_down,
"anomalies": anomalies,
}
def _anomaly(a) -> dict:
"""One anomaly for the report; its times are put in each reader's zone
when the email is written (mail.report_email)."""
from steward.core.formatting import metric_label, metric_value
return {
"name": f"{a.resource_name} — {metric_label(a.metric_name)}",
"review": a.review,
"started_at": a.started_at, "last_started_at": a.last_started_at, "ended_at": a.ended_at,
"occurrences": a.occurrences,
"level": (f"held {metric_value(a.metric_name, a.peak_level)}, usual "
f"{metric_value(a.metric_name, a.usual_hold)}"),
}
async def send_report(app) -> tuple[bool, str]:
"""Email the weekly digest to each account that gets it, and the "Also
send to" addresses, each in its own zone. Returns (success, message)."""
smtp_cfg = app.config.get("SMTP", {})
if not smtp_cfg.get("host"):
return False, "No mail server is set. Set one in Settings → Notifications."
async with app.db_sessionmaker() as db:
to = await recipients(db, "report", smtp_cfg)
if not to:
return False, ("Nobody gets the weekly report: no account has it on with a real email "
"address, and there are no Also send to addresses.")
try:
data = await build_report_data(app)
except Exception as exc:
logger.exception("Failed to build report data")
return False, f"Data collection failed: {exc}"
from steward.core import outbox
# Queued, then tried at once so the button can say what happened; one
# the server didn't take yet stays queued and is retried for an hour.
ok, said = outbox.said(await outbox.send_each(
app, "report", to, lambda r: report_email(smtp_cfg, data, r)))
logger.info("Weekly report: %s", said)
return ok, said
def report_due(now: datetime, day: int, hour: int, tz) -> bool:
"""Whether `now` falls in the scheduled hour, read in the schedule's zone."""
local = now.astimezone(tz)
return local.weekday() == day and local.hour == hour
def schedule_in(day: int, hour: int, saved_tz, view_tz, now: datetime | None = None) -> tuple[int, int]:
"""The schedule's (weekday, hour) as a reader in `view_tz` sees it: the next
time it falls due in `saved_tz`, read on their clock."""
local = (now or datetime.now(timezone.utc)).astimezone(saved_tz)
due = (local + timedelta(days=(day - local.weekday()) % 7)).replace(
hour=hour, minute=0, second=0, microsecond=0)
seen = due.astimezone(view_tz)
return seen.weekday(), seen.hour
async def check_and_send(app) -> None:
"""Hourly scheduler hook: send if day/hour match and not sent recently."""
from steward.core.settings import set_setting
async with app.db_sessionmaker() as db:
enabled = await get_setting(db, "reports.enabled")
if not enabled:
return
schedule_day = int(await get_setting(db, "reports.schedule_day") or 6)
schedule_hour = int(await get_setting(db, "reports.schedule_hour") or 8)
schedule_tz = zone(await get_setting(db, "reports.schedule_tz"))
last_sent_str = await get_setting(db, "reports.last_sent_at") or ""
now = datetime.now(timezone.utc)
if not report_due(now, schedule_day, schedule_hour, schedule_tz):
return
if last_sent_str:
try:
last_sent = datetime.fromisoformat(last_sent_str)
if (now - last_sent).total_seconds() < 23 * 3600:
return # already sent this window
except ValueError:
pass
ok, msg = await send_report(app)
if ok:
async with app.db_sessionmaker() as db:
async with db.begin():
await set_setting(db, "reports.last_sent_at", now.isoformat())
else:
logger.warning("Weekly report send failed: %s", msg)