"""告警收敛:去重 + 聚合。 - 去重:同一 (host_id, rule_id) 在 dedup_window 内不重复产生同类新告警 (Redis SET key NX EX dedup_window,InMemory 语义等价)。 - 聚合:同一 aggregate_key(scope/rule/severity + 时间桶)在 aggregate_window 内多主机同类告警合并为一条,aggregated_count 累加,明细 hosts 列表合并。 - 收敛后的 alert 写入 AlertStore,并发布到 alerts.converged 总线,由通知分发器消费。 """ from __future__ import annotations import uuid from typing import Callable, Dict, List, Optional from .models import Alert, Event, parse_duration from .storage import AlertStore, Cache, MessageBus DEDUP_PREFIX = "hms:dedup:" class AlertConverger: def __init__( self, alert_store: AlertStore, cache: Cache, bus: MessageBus, dedup_window: str = "5m", aggregate_window: str = "1m", alerts_topic: str = "alerts.converged", host_group_resolver: Optional[Callable[[str], str]] = None, notifier: Optional[object] = None, ) -> None: self.alert_store = alert_store self.cache = cache self.bus = bus self.dedup_window = parse_duration(dedup_window) or 300 self.aggregate_window = parse_duration(aggregate_window) or 60 self.alerts_topic = alerts_topic self.host_group_resolver = host_group_resolver or (lambda host_id: "all") self.notifier = notifier def handle_event(self, event: Event) -> Optional[Alert]: if event.status == "firing": return self._handle_firing(event) if event.status == "resolved": return self._handle_resolved(event) return None def _handle_firing(self, event: Event) -> Optional[Alert]: dedup_key = f"{event.host_id}:{event.rule_id}" if not self.cache.set_nx(DEDUP_PREFIX + dedup_key, "1", ttl=self.dedup_window): # 去重窗口内重复事件,抑制新告警 return None bucket = int(event.fired_at // self.aggregate_window) group = self.host_group_resolver(event.host_id) aggregate_key = f"{group}:{event.rule_id}:{event.severity}:{bucket}" alert = Alert( alert_id=f"a-{uuid.uuid4().hex[:16]}", dedup_key=dedup_key, aggregate_key=aggregate_key, severity=event.severity, status="firing", title=self._title(event), detail={ "hosts": [event.host_id], "current": event.agg_value, "threshold": event.threshold, "metric": event.metric, "rule_id": event.rule_id, }, count=1, first_at=event.fired_at, last_at=event.fired_at, notify_channels=list(event.notify_channels), ) stored = self.alert_store.upsert_firing(alert) # 聚合更新时使用已存在告警的渠道列表,避免覆盖丢失 channels = stored.notify_channels or event.notify_channels self._publish(stored) if self.notifier is not None and hasattr(self.notifier, "notify"): self.notifier.notify(stored, channels) return stored def _handle_resolved(self, event: Event) -> Optional[Alert]: dedup_key = f"{event.host_id}:{event.rule_id}" # 恢复事件允许后续再次触发去重键失效 self.cache.delete(DEDUP_PREFIX + dedup_key) alert = self.alert_store.resolve_by_dedup_key(dedup_key, event.fired_at) if alert is not None: self._publish(alert) return alert def _publish(self, alert: Alert) -> None: self.bus.publish(self.alerts_topic, alert.aggregate_key, alert.to_dict()) @staticmethod def _title(event: Event) -> str: return f"{event.metric} {event.operator} {event.threshold} ({event.status})" @staticmethod def _channels(event: Event) -> List[str]: return list(event.notify_channels)