diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6bc6d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ +.coverage +htmlcov/ +.pytest_cache/ +.mypy_cache/ diff --git a/detector/__pycache__/__init__.cpython-310.pyc b/detector/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 1190d6e..0000000 Binary files a/detector/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/detector/__pycache__/__main__.cpython-310.pyc b/detector/__pycache__/__main__.cpython-310.pyc deleted file mode 100644 index cf8d79c..0000000 Binary files a/detector/__pycache__/__main__.cpython-310.pyc and /dev/null differ diff --git a/detector/__pycache__/api.cpython-310.pyc b/detector/__pycache__/api.cpython-310.pyc deleted file mode 100644 index f147dcd..0000000 Binary files a/detector/__pycache__/api.cpython-310.pyc and /dev/null differ diff --git a/detector/__pycache__/app.cpython-310.pyc b/detector/__pycache__/app.cpython-310.pyc deleted file mode 100644 index e876df5..0000000 Binary files a/detector/__pycache__/app.cpython-310.pyc and /dev/null differ diff --git a/detector/__pycache__/config.cpython-310.pyc b/detector/__pycache__/config.cpython-310.pyc deleted file mode 100644 index 1a6c2fa..0000000 Binary files a/detector/__pycache__/config.cpython-310.pyc and /dev/null differ diff --git a/detector/__pycache__/converger.cpython-310.pyc b/detector/__pycache__/converger.cpython-310.pyc deleted file mode 100644 index 3a2dc66..0000000 Binary files a/detector/__pycache__/converger.cpython-310.pyc and /dev/null differ diff --git a/detector/__pycache__/engine.cpython-310.pyc b/detector/__pycache__/engine.cpython-310.pyc deleted file mode 100644 index e388714..0000000 Binary files a/detector/__pycache__/engine.cpython-310.pyc and /dev/null differ diff --git a/detector/__pycache__/models.cpython-310.pyc b/detector/__pycache__/models.cpython-310.pyc deleted file mode 100644 index f6747ff..0000000 Binary files a/detector/__pycache__/models.cpython-310.pyc and /dev/null differ diff --git a/detector/__pycache__/notifier.cpython-310.pyc b/detector/__pycache__/notifier.cpython-310.pyc deleted file mode 100644 index 7a67356..0000000 Binary files a/detector/__pycache__/notifier.cpython-310.pyc and /dev/null differ diff --git a/detector/__pycache__/rule_loader.cpython-310.pyc b/detector/__pycache__/rule_loader.cpython-310.pyc deleted file mode 100644 index 4674d88..0000000 Binary files a/detector/__pycache__/rule_loader.cpython-310.pyc and /dev/null differ diff --git a/detector/__pycache__/storage.cpython-310.pyc b/detector/__pycache__/storage.cpython-310.pyc deleted file mode 100644 index 3911ced..0000000 Binary files a/detector/__pycache__/storage.cpython-310.pyc and /dev/null differ diff --git a/detector/api.py b/detector/api.py index 4cf3e8c..ec52e89 100644 --- a/detector/api.py +++ b/detector/api.py @@ -17,6 +17,7 @@ import json import re import threading import time +import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any, Callable, Dict, List, Optional, Tuple from urllib.parse import urlparse @@ -170,9 +171,10 @@ class APIServer: def handle_create_rule(self, handler) -> None: data = handler._read_json() + # api-design:POST /rules 允许不传 rule_id,服务端自动生成。 + if not data.get("rule_id"): + data["rule_id"] = f"r-{uuid.uuid4().hex[:12]}" rule = MetricRule.from_dict(data) - if not rule.rule_id: - raise _APIError(400, 40001, "rule_id is required") created = self.app.rule_store.create_rule(rule) self.app.rule_loader.load() handler._write_json(200, {"code": 0, "message": "ok", "data": created.to_dict()}) diff --git a/detector/converger.py b/detector/converger.py index a6f9f61..1fe9bfd 100644 --- a/detector/converger.py +++ b/detector/converger.py @@ -73,11 +73,14 @@ class AlertConverger: 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, getattr(stored, "notify_channels", None) or self._channels(event)) + self.notifier.notify(stored, channels) return stored def _handle_resolved(self, event: Event) -> Optional[Alert]: @@ -98,4 +101,4 @@ class AlertConverger: @staticmethod def _channels(event: Event) -> List[str]: - return [] + return list(event.notify_channels) diff --git a/detector/engine.py b/detector/engine.py index 75c98f8..9f477f8 100644 --- a/detector/engine.py +++ b/detector/engine.py @@ -227,6 +227,7 @@ class DetectionEngine: severity=rule.severity, fired_at=now, labels=dict(sample.labels), + notify_channels=list(rule.notify_channels), ) # ------------------------------------------------------------------ # diff --git a/detector/models.py b/detector/models.py index fca3db2..0077113 100644 --- a/detector/models.py +++ b/detector/models.py @@ -153,7 +153,7 @@ class MetricRule: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "MetricRule": return cls( - rule_id=str(data["rule_id"]), + rule_id=str(data.get("rule_id") or ""), name=str(data.get("name") or ""), metric=str(data.get("metric") or ""), aggregation=str(data.get("aggregation") or "avg"), @@ -185,6 +185,7 @@ class Event: severity: str fired_at: float labels: Dict[str, str] = field(default_factory=dict) + notify_channels: List[str] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: return { @@ -199,6 +200,7 @@ class Event: "severity": self.severity, "fired_at": self.fired_at, "labels": self.labels, + "notify_channels": list(self.notify_channels), } @@ -219,6 +221,7 @@ class Alert: ack_status: str = "open" ack_by: Optional[str] = None ack_at: Optional[float] = None + notify_channels: List[str] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: return { @@ -235,4 +238,5 @@ class Alert: "ack_status": self.ack_status, "ack_by": self.ack_by, "ack_at": self.ack_at, + "notify_channels": list(self.notify_channels), } diff --git a/tests/__pycache__/test_api.cpython-310.pyc b/tests/__pycache__/test_api.cpython-310.pyc deleted file mode 100644 index b6c02b5..0000000 Binary files a/tests/__pycache__/test_api.cpython-310.pyc and /dev/null differ diff --git a/tests/__pycache__/test_converger.cpython-310.pyc b/tests/__pycache__/test_converger.cpython-310.pyc deleted file mode 100644 index 98a6b15..0000000 Binary files a/tests/__pycache__/test_converger.cpython-310.pyc and /dev/null differ diff --git a/tests/__pycache__/test_engine.cpython-310.pyc b/tests/__pycache__/test_engine.cpython-310.pyc deleted file mode 100644 index b90f5bb..0000000 Binary files a/tests/__pycache__/test_engine.cpython-310.pyc and /dev/null differ diff --git a/tests/__pycache__/test_models.cpython-310.pyc b/tests/__pycache__/test_models.cpython-310.pyc deleted file mode 100644 index 1ecfda7..0000000 Binary files a/tests/__pycache__/test_models.cpython-310.pyc and /dev/null differ diff --git a/tests/__pycache__/test_notifier.cpython-310.pyc b/tests/__pycache__/test_notifier.cpython-310.pyc deleted file mode 100644 index 4d42212..0000000 Binary files a/tests/__pycache__/test_notifier.cpython-310.pyc and /dev/null differ diff --git a/tests/__pycache__/test_rule_loader.cpython-310.pyc b/tests/__pycache__/test_rule_loader.cpython-310.pyc deleted file mode 100644 index 38f2251..0000000 Binary files a/tests/__pycache__/test_rule_loader.cpython-310.pyc and /dev/null differ diff --git a/tests/test_api.py b/tests/test_api.py index cc2da54..a23777d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -73,6 +73,23 @@ class TestAPI(unittest.TestCase): self.assertEqual(status, 200) self.assertEqual(body["data"]["threshold"], 5.0) + def test_create_rule_autogenerates_id(self): + payload = { + "name": "load high", + "metric": "load_1m", + "aggregation": "avg", + "operator": "gt", + "threshold": 5.0, + "for_duration": "60s", + "severity": "warning", + "scope": {"scope_type": "all"}, + "notify_channels": ["email"], + "enabled": True, + } + status, body = self._request("POST", "/api/v1/rules", payload) + self.assertEqual(status, 200) + self.assertTrue(body["data"]["rule_id"].startswith("r-")) + def test_ingest_triggers_firing_event(self): samples = [ {"name": "cpu_usage", "value": 95.0, "timestamp": float(i * 15), "labels": {"service": "web"}} diff --git a/tests/test_converger.py b/tests/test_converger.py index 0f4b181..419325b 100644 --- a/tests/test_converger.py +++ b/tests/test_converger.py @@ -2,6 +2,7 @@ import unittest from detector.converger import AlertConverger from detector.models import Event +from detector.notifier import Notifier from detector.storage import InMemoryAlertStore, InMemoryCache, InMemoryMessageBus @@ -66,6 +67,17 @@ class TestAlertConverger(unittest.TestCase): self.assertEqual(topic, "alerts.converged") self.assertIn("alert_id", value) + def test_notify_channels_propagated(self): + notifier = Notifier.memory(["email", "webhook"]) + conv = AlertConverger( + self.store, self.cache, self.bus, dedup_window="5m", aggregate_window="1m", + host_group_resolver=lambda h: "web", notifier=notifier, + ) + event = make_event() + event.notify_channels = ["email"] + conv.handle_event(event) + self.assertEqual(notifier.sent[0]["channel"], "email") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_engine.py b/tests/test_engine.py index a2469e5..265b771 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -17,6 +17,7 @@ def make_rule(rule_id="r-cpu", metric="cpu_usage", operator="gt", threshold=90.0 for_duration=for_duration, severity="critical", scope=RuleScope(scope_type="all"), + notify_channels=["email", "webhook"], ) @@ -102,6 +103,15 @@ class TestStateMachine(unittest.TestCase): self.assertEqual(events, []) self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "firing") + def test_event_carries_notify_channels(self): + rule = make_rule(for_duration="60s") + engine = make_engine(rule) + events = [] + for i in range(5): + events.extend(engine.handle(MetricSample(name="cpu_usage", value=95.0, timestamp=float(i * 15)), "h-1")) + self.assertEqual(len(events), 1) + self.assertEqual(events[0].notify_channels, ["email", "webhook"]) + if __name__ == "__main__": unittest.main()