llm-quant/app/utils/alert_dispatcher.py

170 lines
5.6 KiB
Python

"""Dispatch structured alerts to external channels."""
from __future__ import annotations
import json
import logging
import threading
import time
from typing import TYPE_CHECKING, Any, Dict, Iterable, Mapping, MutableMapping, Optional, Sequence
import requests
LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING: # pragma: no cover
from app.utils.config import AlertChannelSettings
else:
AlertChannelSettings = Any # type: ignore[assignment]
_LEVEL_RANK: Dict[str, int] = {
"debug": 10,
"info": 20,
"warning": 30,
"error": 40,
"critical": 50,
}
class _Channel:
"""Runtime wrapper around a configured alert channel."""
__slots__ = (
"settings",
"_lock",
"_last_signature",
"_last_sent",
)
def __init__(self, settings: AlertChannelSettings) -> None:
self.settings = settings
self._lock = threading.Lock()
self._last_signature: Optional[str] = None
self._last_sent: float = 0.0
@property
def name(self) -> str:
return getattr(self.settings, "key", "channel")
def send(self, entry: Mapping[str, Any]) -> None:
if not self._should_send(entry):
return
payload = self._build_payload(entry)
self._deliver(payload)
def _should_send(self, entry: Mapping[str, Any]) -> bool:
level = str(entry.get("level", "warning") or "warning").lower()
level_rank = _LEVEL_RANK.get(level, _LEVEL_RANK["warning"])
threshold = str(getattr(self.settings, "level", "warning") or "warning").lower()
threshold_rank = _LEVEL_RANK.get(threshold, _LEVEL_RANK["warning"])
if level_rank < threshold_rank:
return False
channel_tags: Sequence[str] = getattr(self.settings, "tags", []) or []
if channel_tags:
event_tags = entry.get("tags") or []
if not isinstance(event_tags, Iterable):
event_tags = []
if not set(str(tag) for tag in event_tags).intersection(str(tag) for tag in channel_tags):
return False
cooldown = float(getattr(self.settings, "cooldown_seconds", 0.0) or 0.0)
signature = f"{entry.get('source')}|{entry.get('message')}|{level}"
if cooldown > 0:
now = time.monotonic()
with self._lock:
if self._last_signature == signature and (now - self._last_sent) < cooldown:
return False
self._last_signature = signature
self._last_sent = now
return True
def _build_payload(self, entry: Mapping[str, Any]) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"source": entry.get("source"),
"message": entry.get("message"),
"detail": entry.get("detail"),
"timestamp": entry.get("timestamp"),
"level": entry.get("level"),
}
tags = entry.get("tags")
if isinstance(tags, Iterable) and not isinstance(tags, (str, bytes)):
payload["tags"] = list(tags)
if "payload" in entry and entry["payload"] is not None:
payload["payload"] = entry["payload"]
extra_params = getattr(self.settings, "extra_params", None)
if isinstance(extra_params, Mapping):
for key, value in extra_params.items():
payload.setdefault(key, value)
return payload
def _deliver(self, payload: Mapping[str, Any]) -> None:
url = getattr(self.settings, "url", "")
if not url:
return
method = str(getattr(self.settings, "method", "POST") or "POST").upper()
timeout = float(getattr(self.settings, "timeout", 3.0) or 3.0)
headers: MutableMapping[str, str] = {}
raw_headers = getattr(self.settings, "headers", None)
if isinstance(raw_headers, Mapping):
headers = {str(k): str(v) for k, v in raw_headers.items()}
headers.setdefault("Content-Type", "application/json")
body = json.dumps(payload, ensure_ascii=False)
signing_secret = getattr(self.settings, "signing_secret", None)
if signing_secret:
import hashlib
import hmac
digest = hmac.new(
str(signing_secret).encode("utf-8"),
body.encode("utf-8"),
hashlib.sha256,
).hexdigest()
headers.setdefault("X-Signature", digest)
try:
requests.request(
method=method,
url=url,
data=body,
headers=headers,
timeout=timeout,
)
except Exception: # noqa: BLE001
LOGGER.exception("发送告警失败: channel=%s", self.name)
class AlertDispatcher:
"""Singleton-style dispatcher coordinating channel delivery."""
def __init__(self) -> None:
self._channels: Dict[str, _Channel] = {}
self._lock = threading.Lock()
def configure(self, configs: Mapping[str, AlertChannelSettings]) -> None:
active: Dict[str, _Channel] = {}
for key, cfg in configs.items():
if not cfg or not getattr(cfg, "enabled", True):
continue
if not getattr(cfg, "url", ""):
continue
channel = _Channel(cfg)
active[key] = channel
with self._lock:
self._channels = active
def dispatch(self, entry: Mapping[str, Any]) -> None:
if not self._channels:
return
for channel in list(self._channels.values()):
channel.send(entry)
_DISPATCHER = AlertDispatcher()
def get_dispatcher() -> AlertDispatcher:
return _DISPATCHER