develop: 完善通知渠道透传与规则ID生成,清理pycache

This commit is contained in:
Pipeline Agent 2026-08-15 01:12:52 +08:00
parent a0236028cd
commit 1cd9df78e4
25 changed files with 68 additions and 5 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.eggs/
build/
dist/
.venv/
venv/
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/

View File

@ -17,6 +17,7 @@ import json
import re import re
import threading import threading
import time import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
@ -170,9 +171,10 @@ class APIServer:
def handle_create_rule(self, handler) -> None: def handle_create_rule(self, handler) -> None:
data = handler._read_json() 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) 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) created = self.app.rule_store.create_rule(rule)
self.app.rule_loader.load() self.app.rule_loader.load()
handler._write_json(200, {"code": 0, "message": "ok", "data": created.to_dict()}) handler._write_json(200, {"code": 0, "message": "ok", "data": created.to_dict()})

View File

@ -73,11 +73,14 @@ class AlertConverger:
count=1, count=1,
first_at=event.fired_at, first_at=event.fired_at,
last_at=event.fired_at, last_at=event.fired_at,
notify_channels=list(event.notify_channels),
) )
stored = self.alert_store.upsert_firing(alert) stored = self.alert_store.upsert_firing(alert)
# 聚合更新时使用已存在告警的渠道列表,避免覆盖丢失
channels = stored.notify_channels or event.notify_channels
self._publish(stored) self._publish(stored)
if self.notifier is not None and hasattr(self.notifier, "notify"): 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 return stored
def _handle_resolved(self, event: Event) -> Optional[Alert]: def _handle_resolved(self, event: Event) -> Optional[Alert]:
@ -98,4 +101,4 @@ class AlertConverger:
@staticmethod @staticmethod
def _channels(event: Event) -> List[str]: def _channels(event: Event) -> List[str]:
return [] return list(event.notify_channels)

View File

@ -227,6 +227,7 @@ class DetectionEngine:
severity=rule.severity, severity=rule.severity,
fired_at=now, fired_at=now,
labels=dict(sample.labels), labels=dict(sample.labels),
notify_channels=list(rule.notify_channels),
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@ -153,7 +153,7 @@ class MetricRule:
@classmethod @classmethod
def from_dict(cls, data: Dict[str, Any]) -> "MetricRule": def from_dict(cls, data: Dict[str, Any]) -> "MetricRule":
return cls( return cls(
rule_id=str(data["rule_id"]), rule_id=str(data.get("rule_id") or ""),
name=str(data.get("name") or ""), name=str(data.get("name") or ""),
metric=str(data.get("metric") or ""), metric=str(data.get("metric") or ""),
aggregation=str(data.get("aggregation") or "avg"), aggregation=str(data.get("aggregation") or "avg"),
@ -185,6 +185,7 @@ class Event:
severity: str severity: str
fired_at: float fired_at: float
labels: Dict[str, str] = field(default_factory=dict) labels: Dict[str, str] = field(default_factory=dict)
notify_channels: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { return {
@ -199,6 +200,7 @@ class Event:
"severity": self.severity, "severity": self.severity,
"fired_at": self.fired_at, "fired_at": self.fired_at,
"labels": self.labels, "labels": self.labels,
"notify_channels": list(self.notify_channels),
} }
@ -219,6 +221,7 @@ class Alert:
ack_status: str = "open" ack_status: str = "open"
ack_by: Optional[str] = None ack_by: Optional[str] = None
ack_at: Optional[float] = None ack_at: Optional[float] = None
notify_channels: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { return {
@ -235,4 +238,5 @@ class Alert:
"ack_status": self.ack_status, "ack_status": self.ack_status,
"ack_by": self.ack_by, "ack_by": self.ack_by,
"ack_at": self.ack_at, "ack_at": self.ack_at,
"notify_channels": list(self.notify_channels),
} }

View File

@ -73,6 +73,23 @@ class TestAPI(unittest.TestCase):
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(body["data"]["threshold"], 5.0) 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): def test_ingest_triggers_firing_event(self):
samples = [ samples = [
{"name": "cpu_usage", "value": 95.0, "timestamp": float(i * 15), "labels": {"service": "web"}} {"name": "cpu_usage", "value": 95.0, "timestamp": float(i * 15), "labels": {"service": "web"}}

View File

@ -2,6 +2,7 @@ import unittest
from detector.converger import AlertConverger from detector.converger import AlertConverger
from detector.models import Event from detector.models import Event
from detector.notifier import Notifier
from detector.storage import InMemoryAlertStore, InMemoryCache, InMemoryMessageBus from detector.storage import InMemoryAlertStore, InMemoryCache, InMemoryMessageBus
@ -66,6 +67,17 @@ class TestAlertConverger(unittest.TestCase):
self.assertEqual(topic, "alerts.converged") self.assertEqual(topic, "alerts.converged")
self.assertIn("alert_id", value) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -17,6 +17,7 @@ def make_rule(rule_id="r-cpu", metric="cpu_usage", operator="gt", threshold=90.0
for_duration=for_duration, for_duration=for_duration,
severity="critical", severity="critical",
scope=RuleScope(scope_type="all"), scope=RuleScope(scope_type="all"),
notify_channels=["email", "webhook"],
) )
@ -102,6 +103,15 @@ class TestStateMachine(unittest.TestCase):
self.assertEqual(events, []) self.assertEqual(events, [])
self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "firing") 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__": if __name__ == "__main__":
unittest.main() unittest.main()