170 lines
5.6 KiB
Python
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
|