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 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-designPOST /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()})

View File

@ -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)

View File

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

View File

@ -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),
}

View File

@ -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"}}

View File

@ -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()

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,
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()