#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>
219 lines
8.9 KiB
Python
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)
|