feat: notification cooldown dedup for events and problems (notif-31)

Store last notify time by dedup_key/fingerprint, gate dispatch before
channels; config SAC_NOTIFY_*_COOLDOWN_SEC, migration 008.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:24:01 +10:00
parent 1a70980920
commit 4167687dec
12 changed files with 309 additions and 14 deletions
@@ -0,0 +1,100 @@
"""Cooldown / dedup for outbound notifications (F-NOT-03)."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import Event, Problem
from app.models.notification_cooldown import NotificationCooldown
logger = logging.getLogger(__name__)
COOLDOWN_KIND_EVENT = "event"
COOLDOWN_KIND_PROBLEM = "problem"
# Не душим служебные проверки ingest
_EVENT_COOLDOWN_EXEMPT_TYPES = frozenset({"agent.test"})
def _as_utc(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def build_event_cooldown_key(event: Event) -> str:
if event.dedup_key and str(event.dedup_key).strip():
return f"event:{event.dedup_key.strip()}"
details: dict[str, Any] = event.details if isinstance(event.details, dict) else {}
host = event.host.hostname if event.host else "unknown"
ip = str(details.get("source_ip") or details.get("ip_address") or details.get("ip") or "").strip()
user = str(details.get("user") or details.get("username") or "").strip()
return f"event:{event.type}|{host}|{ip}|{user}"
def build_problem_cooldown_key(problem: Problem) -> str:
fp = (problem.fingerprint or "").strip() or f"id:{problem.id}"
return f"problem:{fp}"
def _cooldown_seconds_for_kind(kind: str) -> int:
settings = get_settings()
if not settings.sac_notify_cooldown_enabled:
return 0
if kind == COOLDOWN_KIND_PROBLEM:
return max(0, int(settings.sac_notify_problem_cooldown_sec))
return max(0, int(settings.sac_notify_event_cooldown_sec))
def allow_notify(db: Session | None, *, key: str, kind: str) -> bool:
"""True = можно слать сейчас; при True обновляет last_notified_at."""
if kind == COOLDOWN_KIND_EVENT:
cooldown_sec = _cooldown_seconds_for_kind(COOLDOWN_KIND_EVENT)
else:
cooldown_sec = _cooldown_seconds_for_kind(COOLDOWN_KIND_PROBLEM)
if cooldown_sec <= 0:
return True
if db is None:
return True
now = datetime.now(timezone.utc)
row = db.get(NotificationCooldown, key)
if row is not None:
last = _as_utc(row.last_notified_at)
elapsed = (now - last).total_seconds()
if elapsed >= 0 and elapsed < cooldown_sec:
logger.info(
"notify cooldown skip kind=%s key=%s elapsed=%.0fs need=%ss",
kind,
key[:120],
elapsed,
cooldown_sec,
)
return False
if row is None:
db.add(NotificationCooldown(cooldown_key=key, kind=kind, last_notified_at=now))
else:
row.kind = kind
row.last_notified_at = now
db.flush()
return True
def should_notify_event(event: Event, db: Session | None) -> bool:
if event.type in _EVENT_COOLDOWN_EXEMPT_TYPES:
return True
key = build_event_cooldown_key(event)
return allow_notify(db, key=key, kind=COOLDOWN_KIND_EVENT)
def should_notify_problem(problem: Problem, db: Session | None) -> bool:
key = build_problem_cooldown_key(problem)
return allow_notify(db, key=key, kind=COOLDOWN_KIND_PROBLEM)
+25 -9
View File
@@ -1,18 +1,19 @@
"""Dispatch ingest notifications per global policy (severity → channels)."""
import logging
from sqlalchemy.orm import Session
from app.models import Event, Problem
from app.services import email_notify, telegram_notify, webhook_notify
from app.services.notification_cooldown import should_notify_event, should_notify_problem
from app.services.notification_policy import get_effective_notification_policy
from app.services.notification_severity import severity_meets_minimum
from app.services.notification_settings import CHANNEL_EMAIL, CHANNEL_TELEGRAM, CHANNEL_WEBHOOK
logger = logging.getLogger(__name__)
def notify_event(event: Event, *, db: Session | None = None) -> None:
policy = get_effective_notification_policy(db)
if not severity_meets_minimum(event.severity, policy.min_severity):
return
def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None:
if policy.use_telegram:
telegram_notify.notify_event(event, db=db, apply_policy_gate=False)
if policy.use_webhook:
@@ -21,13 +22,28 @@ def notify_event(event: Event, *, db: Session | None = None) -> None:
email_notify.notify_event(event, db=db, apply_policy_gate=False)
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
policy = get_effective_notification_policy(db)
if not severity_meets_minimum(problem.severity, policy.min_severity):
return
def _dispatch_problem_channels(problem: Problem, event: Event | None, *, db: Session | None, policy) -> None:
if policy.use_telegram:
telegram_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
if policy.use_webhook:
webhook_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
if policy.use_email:
email_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
def notify_event(event: Event, *, db: Session | None = None) -> None:
policy = get_effective_notification_policy(db)
if not severity_meets_minimum(event.severity, policy.min_severity):
return
if not should_notify_event(event, db):
return
_dispatch_event_channels(event, db=db, policy=policy)
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
policy = get_effective_notification_policy(db)
if not severity_meets_minimum(problem.severity, policy.min_severity):
return
if not should_notify_problem(problem, db):
return
_dispatch_problem_channels(problem, event, db=db, policy=policy)