AgentEvalTool/backend/agenteval/intelligent_eval/alerts.py
sinohqb f85eca11ca
All checks were successful
CI / test (push) Successful in 3m57s
fix(alerts): webhook retry + dedupe (resolves §6.2)
_send_webhook:
- Dedupe: skip if alert.webhook_sent is already True (guards against
  repeated check_alerts ticks re-sending the same alert).
- Retry: up to 3 attempts with exponential backoff (1s, 2s) before
  giving up. webhook_sent=True is persisted only on success.

Two xfail guards in test_openclaw_client_and_webhook now pass (876/4 xfail).
2026-08-14 14:54:57 +08:00

235 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Alert rules and notifications for cron pool (告警规则和通知).
Alert rules:
- Pool utilization > 90% for 10 minutes
- Task backlog > 50
- Stuck rate > 10%
Notifications:
- Log alerts
- Optional webhook notifications
"""
import logging
from datetime import datetime
from typing import Optional
import httpx
from sqlmodel import Field, Session, SQLModel, select
from agenteval.intelligent_eval.metrics import (
calculate_pool_utilization,
calculate_stuck_rate,
calculate_task_backlog,
)
from agenteval.storage.db import utc_now
_logger = logging.getLogger("agenteval")
class AlertHistoryDB(SQLModel, table=True):
"""Alert history database record."""
__tablename__ = "cron_pool_alert_history"
id: Optional[str] = Field(default=None, primary_key=True)
alert_type: str = Field(index=True) # pool_utilization / task_backlog / stuck_rate
severity: str = Field(index=True) # warning / critical
message: str
metric_value: float
threshold: float
created_at: datetime = Field(default_factory=utc_now)
resolved_at: Optional[datetime] = None
webhook_sent: bool = False
class AlertRule:
"""Alert rule definition."""
def __init__(
self,
name: str,
metric_func,
threshold: float,
severity: str,
duration_minutes: int = 0,
):
self.name = name
self.metric_func = metric_func
self.threshold = threshold
self.severity = severity
self.duration_minutes = duration_minutes
self.triggered_at: Optional[datetime] = None
class AlertManager:
"""Manages alert rules and notifications."""
def __init__(self, session: Session, webhook_url: Optional[str] = None):
self.session = session
self.webhook_url = webhook_url
self.rules = [
AlertRule(
name="pool_utilization",
metric_func=calculate_pool_utilization,
threshold=0.9,
severity="warning",
duration_minutes=10,
),
AlertRule(
name="task_backlog",
metric_func=calculate_task_backlog,
threshold=50,
severity="warning",
duration_minutes=0,
),
AlertRule(
name="stuck_rate",
metric_func=calculate_stuck_rate,
threshold=0.1,
severity="critical",
duration_minutes=0,
),
]
def check_rules(self) -> list[AlertHistoryDB]:
"""Check all alert rules and create alerts if triggered.
Returns:
List of newly created alerts
"""
alerts = []
for rule in self.rules:
metric_value = rule.metric_func(self.session)
# Check if threshold exceeded
if metric_value > rule.threshold:
# Check duration requirement
if rule.duration_minutes > 0:
if rule.triggered_at is None:
rule.triggered_at = utc_now()
continue
duration = (utc_now() - rule.triggered_at).total_seconds() / 60
if duration < rule.duration_minutes:
continue
else:
rule.triggered_at = utc_now()
# Create alert
alert = self._create_alert(rule, metric_value)
alerts.append(alert)
# Send webhook notification
if self.webhook_url:
self._send_webhook(alert)
else:
# Reset trigger time
rule.triggered_at = None
return alerts
def _create_alert(self, rule: AlertRule, metric_value: float) -> AlertHistoryDB:
"""Create an alert history record."""
message = f"{rule.name}: {metric_value:.2f} exceeds threshold {rule.threshold}"
alert = AlertHistoryDB(
id=f"alert-{utc_now().timestamp()}",
alert_type=rule.name,
severity=rule.severity,
message=message,
metric_value=metric_value,
threshold=rule.threshold,
)
self.session.add(alert)
self.session.commit()
self.session.refresh(alert)
_logger.warning(f"Alert triggered: {message}")
return alert
def _send_webhook(self, alert: AlertHistoryDB, max_retries: int = 3) -> None:
"""Send webhook with retry and dedupe.
P1 真问题修复§6.2:
- **Dedupe**: if ``alert.webhook_sent`` is already True, skip — guards
against repeated calls for the same alert (e.g. consecutive
``check_alerts`` ticks).
- **Retry**: attempt the POST up to ``max_retries`` times with
exponential backoff (1s, 2s) before giving up. Persist
``webhook_sent=True`` only on success.
"""
if not self.webhook_url:
return
if alert.webhook_sent:
return
payload = {
"alert_id": alert.id,
"alert_type": alert.alert_type,
"severity": alert.severity,
"message": alert.message,
"metric_value": alert.metric_value,
"threshold": alert.threshold,
"timestamp": alert.created_at.isoformat(),
}
import time
last_err: Exception | None = None
for attempt in range(max_retries):
try:
response = httpx.post(self.webhook_url, json=payload, timeout=5.0)
response.raise_for_status()
alert.webhook_sent = True
self.session.commit()
_logger.info(f"Webhook sent for alert {alert.id} (attempt {attempt + 1})")
return
except Exception as e:
last_err = e
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
_logger.error(f"Failed to send webhook for alert {alert.id} after {max_retries} attempts: {last_err}")
def get_alert_history(self, limit: int = 100) -> list[AlertHistoryDB]:
"""Get alert history.
Returns:
List of alerts, ordered by created_at descending
"""
alerts = self.session.exec(
select(AlertHistoryDB)
.order_by(AlertHistoryDB.created_at.desc())
.limit(limit)
).all()
return list(alerts)
def get_unresolved_alerts(self) -> list[AlertHistoryDB]:
"""Get unresolved alerts.
Returns:
List of unresolved alerts
"""
alerts = self.session.exec(
select(AlertHistoryDB).where(AlertHistoryDB.resolved_at.is_(None))
).all()
return list(alerts)
def resolve_alert(self, alert_id: str) -> bool:
"""Resolve an alert.
Returns:
True if alert was resolved, False if not found
"""
alert = self.session.get(AlertHistoryDB, alert_id)
if alert is None:
return False
alert.resolved_at = utc_now()
self.session.commit()
return True