develop: 开发 threshold-event-detector 检测模块
This commit is contained in:
parent
999270a518
commit
a0236028cd
85
README.md
85
README.md
@ -1,2 +1,87 @@
|
|||||||
# threshold-event-detector
|
# threshold-event-detector
|
||||||
|
|
||||||
|
主机监控系统(HMS)指标阈值事件检测模块。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
1. **规则加载与热更新**:从 MySQL `metric_rule` / `rule_scope` 读取规则,写入 Redis
|
||||||
|
缓存(`hms:rule:cache:{rule_id}`、`hms:rule:version`),后台线程按版本号轮询热加载。
|
||||||
|
2. **检测引擎**:消费 `metrics.samples`,按 `(host_id, rule_id)` 维护滑动窗口与状态机
|
||||||
|
(`OK → FIRING → PENDING → OK`),阈值比对触发 `firing` / `resolved` 事件。
|
||||||
|
3. **事件写入与告警收敛**:事件写入 `event` 表;告警按 `(host, rule)` 去重、
|
||||||
|
按 `scope/rule/severity + 时间桶` 聚合,输出 `alert` 并发布到 `alerts.converged`。
|
||||||
|
4. **通知渠道封装**:email / webhook / dingtalk / wechat 统一封装。
|
||||||
|
5. **REST API**:阈值规则管理、事件告警查询与确认/关闭、样本接入。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Python 3.10+(标准库实现核心逻辑与 HTTP API,零强制第三方依赖)。
|
||||||
|
- 可选依赖:`pymysql`(MySQL)、`redis`(Redis)、`kafka-python`(Kafka)。
|
||||||
|
未安装时自动回退到进程内内存实现,便于本地运行与单元测试。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
threshold-event-detector/
|
||||||
|
├── detector/
|
||||||
|
│ ├── __init__.py # 包说明
|
||||||
|
│ ├── __main__.py # 入口(python -m detector)
|
||||||
|
│ ├── models.py # 领域模型
|
||||||
|
│ ├── config.py # 配置
|
||||||
|
│ ├── storage.py # 存储/缓存/总线抽象 + 内存/MySQL/Redis/Kafka 适配器
|
||||||
|
│ ├── rule_loader.py # 规则加载与热更新
|
||||||
|
│ ├── engine.py # 检测引擎(滑动窗口 + 状态机)
|
||||||
|
│ ├── converger.py # 告警收敛(去重 + 聚合)
|
||||||
|
│ ├── notifier.py # 通知渠道封装
|
||||||
|
│ ├── api.py # REST API
|
||||||
|
│ └── app.py # 应用装配
|
||||||
|
└── tests/ # 单元测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 零依赖本地运行(进程内存储 + 示例规则)
|
||||||
|
python3 -m detector --addr 0.0.0.0:8080
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
curl http://127.0.0.1:8080/healthz
|
||||||
|
|
||||||
|
# 查看规则
|
||||||
|
curl http://127.0.0.1:8080/api/v1/rules
|
||||||
|
|
||||||
|
# 样本接入(端到端联调)
|
||||||
|
curl -X POST http://127.0.0.1:8080/api/v1/ingest \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"host_id":"h-001","host_group":"web","samples":[
|
||||||
|
{"name":"cpu_usage","value":95,"timestamp":1700000000,"labels":{"service":"web"}}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置(环境变量)
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `HMS_HTTP_ADDR` | `0.0.0.0:8080` | HTTP 监听地址 |
|
||||||
|
| `HMS_EVALUATION_INTERVAL` | `15s` | 评估/恢复观察周期 |
|
||||||
|
| `HMS_DEDUP_WINDOW` | `5m` | 告警去重窗口 |
|
||||||
|
| `HMS_AGGREGATE_WINDOW` | `1m` | 告警聚合窗口 |
|
||||||
|
| `HMS_RULE_STORE_DRIVER` | `memory` | 规则仓库驱动 `memory`/`mysql` |
|
||||||
|
| `HMS_EVENT_STORE_DRIVER` | `memory` | 事件仓库驱动 |
|
||||||
|
| `HMS_ALERT_STORE_DRIVER` | `memory` | 告警仓库驱动 |
|
||||||
|
| `HMS_CACHE_DRIVER` | `memory` | 缓存驱动 `memory`/`redis` |
|
||||||
|
| `HMS_BUS_DRIVER` | `memory` | 总线驱动 `memory`/`kafka` |
|
||||||
|
| `HMS_MYSQL_DSN` | 空 | MySQL DSN(`mysql://user:pass@host:port/hms`) |
|
||||||
|
| `HMS_REDIS_URL` | `redis://127.0.0.1:6379/0` | Redis URL |
|
||||||
|
| `HMS_KAFKA_BOOTSTRAP` | `127.0.0.1:9092` | Kafka bootstrap |
|
||||||
|
|
||||||
|
## 设计文档
|
||||||
|
|
||||||
|
- `docs/01-design/architecture.md`(5.2 threshold-event-detector)
|
||||||
|
- `docs/01-design/database-design.md`
|
||||||
|
- `docs/01-design/api-design.md`
|
||||||
|
|||||||
13
detector/__init__.py
Normal file
13
detector/__init__.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""threshold-event-detector 指标阈值事件检测模块。
|
||||||
|
|
||||||
|
该包实现:
|
||||||
|
1) 阈值规则加载与配置(MySQL metric_rule/rule_scope + Redis 缓存,支持热加载);
|
||||||
|
2) 检测引擎(对采集指标做阈值比对,状态机 OK/FIRING/PENDING 触发事件);
|
||||||
|
3) 事件写入(event 表)与告警收敛(Redis 去重/聚合 + 通知渠道封装);
|
||||||
|
4) REST API(阈值规则管理、事件告警查询)。
|
||||||
|
|
||||||
|
默认使用进程内存储(Memory),方便本地运行与单元测试;
|
||||||
|
MySQL / Redis / Kafka 适配器以可选依赖方式提供,接入真实中间件时启用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
54
detector/__main__.py
Normal file
54
detector/__main__.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""threshold-event-detector 入口(python -m detector)。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python -m detector [--addr 0.0.0.0:8080] [--no-seed]
|
||||||
|
|
||||||
|
默认以进程内存储启动(零依赖),写入示例规则并监听 REST API;可通过环境变量
|
||||||
|
HMS_* 切换 MySQL / Redis / Kafka 驱动(需安装对应可选依赖)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import signal
|
||||||
|
|
||||||
|
from .api import APIServer
|
||||||
|
from .app import DetectorApp
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="threshold-event-detector")
|
||||||
|
parser.add_argument("--addr", default=None, help="HTTP listen address, e.g. 0.0.0.0:8080")
|
||||||
|
parser.add_argument("--no-seed", action="store_true", help="do not seed demo rules")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
config = Config.from_env()
|
||||||
|
if args.addr:
|
||||||
|
config.http_addr = args.addr
|
||||||
|
|
||||||
|
app = DetectorApp(config)
|
||||||
|
if not args.no_seed and config.seed_demo_rules:
|
||||||
|
app.seed_demo_rules()
|
||||||
|
app.start()
|
||||||
|
|
||||||
|
api = APIServer(app, config.http_addr)
|
||||||
|
print(f"[threshold-event-detector] listening on http://{config.http_addr}", flush=True)
|
||||||
|
|
||||||
|
def _stop(signum, frame): # noqa: ARG001
|
||||||
|
print("\n[threshold-event-detector] shutting down...", flush=True)
|
||||||
|
api.shutdown()
|
||||||
|
app.stop()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, _stop)
|
||||||
|
signal.signal(signal.SIGTERM, _stop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
api.serve_forever()
|
||||||
|
finally:
|
||||||
|
app.stop()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
BIN
detector/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
detector/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
detector/__pycache__/__main__.cpython-310.pyc
Normal file
BIN
detector/__pycache__/__main__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
detector/__pycache__/api.cpython-310.pyc
Normal file
BIN
detector/__pycache__/api.cpython-310.pyc
Normal file
Binary file not shown.
BIN
detector/__pycache__/app.cpython-310.pyc
Normal file
BIN
detector/__pycache__/app.cpython-310.pyc
Normal file
Binary file not shown.
BIN
detector/__pycache__/config.cpython-310.pyc
Normal file
BIN
detector/__pycache__/config.cpython-310.pyc
Normal file
Binary file not shown.
BIN
detector/__pycache__/converger.cpython-310.pyc
Normal file
BIN
detector/__pycache__/converger.cpython-310.pyc
Normal file
Binary file not shown.
BIN
detector/__pycache__/engine.cpython-310.pyc
Normal file
BIN
detector/__pycache__/engine.cpython-310.pyc
Normal file
Binary file not shown.
BIN
detector/__pycache__/models.cpython-310.pyc
Normal file
BIN
detector/__pycache__/models.cpython-310.pyc
Normal file
Binary file not shown.
BIN
detector/__pycache__/notifier.cpython-310.pyc
Normal file
BIN
detector/__pycache__/notifier.cpython-310.pyc
Normal file
Binary file not shown.
BIN
detector/__pycache__/rule_loader.cpython-310.pyc
Normal file
BIN
detector/__pycache__/rule_loader.cpython-310.pyc
Normal file
Binary file not shown.
BIN
detector/__pycache__/storage.cpython-310.pyc
Normal file
BIN
detector/__pycache__/storage.cpython-310.pyc
Normal file
Binary file not shown.
301
detector/api.py
Normal file
301
detector/api.py
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
"""REST API(stdlib http.server 实现,零第三方依赖)。
|
||||||
|
|
||||||
|
接口覆盖 api-design.md 中与本模块相关的部分:
|
||||||
|
- 健康检查:GET /healthz、GET /readyz
|
||||||
|
- 规则管理:GET/POST /api/v1/rules、GET/PUT/DELETE /api/v1/rules/{id}、
|
||||||
|
POST /api/v1/rules/{id}/enable|disable
|
||||||
|
- 事件查询:GET /api/v1/events
|
||||||
|
- 告警查询与管理:GET /api/v1/alerts、GET /api/v1/alerts/{id}、
|
||||||
|
POST /api/v1/alerts/{id}/ack、POST /api/v1/alerts/{id}/close、
|
||||||
|
POST /api/v1/alerts/batch-ack
|
||||||
|
- 内部样本接入:POST /api/v1/ingest(供无 Kafka 环境端到端联调)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from .app import DetectorApp
|
||||||
|
from .models import MetricRule, MetricSample
|
||||||
|
|
||||||
|
# 路由条目:(method, path_regex, handler)
|
||||||
|
Route = Tuple[str, str, Callable]
|
||||||
|
|
||||||
|
|
||||||
|
class APIServer:
|
||||||
|
def __init__(self, app: DetectorApp, addr: str = "0.0.0.0:8080") -> None:
|
||||||
|
self.app = app
|
||||||
|
host, port = self._split_addr(addr)
|
||||||
|
self.server = ThreadingHTTPServer((host, port), self._handler_factory())
|
||||||
|
self.routes: List[Route] = [
|
||||||
|
("GET", r"^/healthz$", self.handle_healthz),
|
||||||
|
("GET", r"^/readyz$", self.handle_readyz),
|
||||||
|
("GET", r"^/api/v1/rules$", self.handle_list_rules),
|
||||||
|
("POST", r"^/api/v1/rules$", self.handle_create_rule),
|
||||||
|
("GET", r"^/api/v1/rules/([^/]+)$", self.handle_get_rule),
|
||||||
|
("PUT", r"^/api/v1/rules/([^/]+)$", self.handle_update_rule),
|
||||||
|
("DELETE", r"^/api/v1/rules/([^/]+)$", self.handle_delete_rule),
|
||||||
|
("POST", r"^/api/v1/rules/([^/]+)/enable$", self.handle_enable_rule),
|
||||||
|
("POST", r"^/api/v1/rules/([^/]+)/disable$", self.handle_disable_rule),
|
||||||
|
("GET", r"^/api/v1/events$", self.handle_list_events),
|
||||||
|
("GET", r"^/api/v1/alerts$", self.handle_list_alerts),
|
||||||
|
("POST", r"^/api/v1/alerts/batch-ack$", self.handle_batch_ack),
|
||||||
|
("GET", r"^/api/v1/alerts/([^/]+)$", self.handle_get_alert),
|
||||||
|
("POST", r"^/api/v1/alerts/([^/]+)/ack$", self.handle_ack_alert),
|
||||||
|
("POST", r"^/api/v1/alerts/([^/]+)/close$", self.handle_close_alert),
|
||||||
|
("POST", r"^/api/v1/ingest$", self.handle_ingest),
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_addr(addr: str) -> Tuple[str, int]:
|
||||||
|
if ":" in addr:
|
||||||
|
host, port = addr.rsplit(":", 1)
|
||||||
|
return host, int(port)
|
||||||
|
return addr, 8080
|
||||||
|
|
||||||
|
def _handler_factory(self) -> type:
|
||||||
|
api = self
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "ThresholdEventDetector/0.1"
|
||||||
|
|
||||||
|
def do_GET(self): # noqa: N802
|
||||||
|
self._dispatch("GET")
|
||||||
|
|
||||||
|
def do_POST(self): # noqa: N802
|
||||||
|
self._dispatch("POST")
|
||||||
|
|
||||||
|
def do_PUT(self): # noqa: N802
|
||||||
|
self._dispatch("PUT")
|
||||||
|
|
||||||
|
def do_DELETE(self): # noqa: N802
|
||||||
|
self._dispatch("DELETE")
|
||||||
|
|
||||||
|
def _dispatch(self, method: str) -> None:
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
for route_method, pattern, handler in api.routes:
|
||||||
|
if route_method != method:
|
||||||
|
continue
|
||||||
|
m = re.match(pattern, parsed.path)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
handler(self, *m.groups())
|
||||||
|
except _APIError as exc:
|
||||||
|
self._write_json(exc.status, {"code": exc.code, "message": exc.message, "data": None})
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._write_json(500, {"code": 50000, "message": f"internal error: {exc}", "data": None})
|
||||||
|
return
|
||||||
|
self._write_json(404, {"code": 40400, "message": "not found", "data": None})
|
||||||
|
|
||||||
|
def _read_json(self) -> Dict[str, Any]:
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
if length <= 0:
|
||||||
|
return {}
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
def _query(self) -> Dict[str, str]:
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
result: Dict[str, str] = {}
|
||||||
|
if parsed.query:
|
||||||
|
for pair in parsed.query.split("&"):
|
||||||
|
if "=" in pair:
|
||||||
|
k, v = pair.split("=", 1)
|
||||||
|
result[k] = v
|
||||||
|
else:
|
||||||
|
result[pair] = ""
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _write_json(self, status: int, body: Dict[str, Any]) -> None:
|
||||||
|
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args) -> None: # noqa: A002
|
||||||
|
# 静默默认请求日志,便于测试输出整洁;生产可重写为 logging。
|
||||||
|
pass
|
||||||
|
|
||||||
|
return Handler
|
||||||
|
|
||||||
|
def serve_forever(self) -> None:
|
||||||
|
self.server.serve_forever()
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self.server.shutdown()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 健康检查
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def handle_healthz(self, handler) -> None:
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": {"status": "ok"}})
|
||||||
|
|
||||||
|
def handle_readyz(self, handler) -> None:
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": {"status": "ok", "deps": {"rule_store": "up"}}})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 规则管理
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def handle_list_rules(self, handler) -> None:
|
||||||
|
q = handler._query()
|
||||||
|
rules = self.app.rule_loader.get_rules()
|
||||||
|
if q.get("metric"):
|
||||||
|
rules = [r for r in rules if r.metric == q["metric"]]
|
||||||
|
if q.get("enabled") in ("true", "false"):
|
||||||
|
enabled = q["enabled"] == "true"
|
||||||
|
rules = [r for r in rules if r.enabled == enabled]
|
||||||
|
if q.get("severity"):
|
||||||
|
rules = [r for r in rules if r.severity == q["severity"]]
|
||||||
|
page = int(q.get("page", "1"))
|
||||||
|
page_size = min(int(q.get("page_size", "20")), 200)
|
||||||
|
total = len(rules)
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
items = [r.to_dict() for r in rules[start : start + page_size]]
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": {"total": total, "items": items}})
|
||||||
|
|
||||||
|
def handle_get_rule(self, handler, rule_id: str) -> None:
|
||||||
|
rule = self.app.rule_loader.get_rule(rule_id)
|
||||||
|
if rule is None:
|
||||||
|
raise _APIError(404, 40400, f"rule {rule_id} not found")
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": rule.to_dict()})
|
||||||
|
|
||||||
|
def handle_create_rule(self, handler) -> None:
|
||||||
|
data = handler._read_json()
|
||||||
|
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()})
|
||||||
|
|
||||||
|
def handle_update_rule(self, handler, rule_id: str) -> None:
|
||||||
|
data = handler._read_json()
|
||||||
|
data["rule_id"] = rule_id
|
||||||
|
updated = self.app.rule_store.update_rule(MetricRule.from_dict(data))
|
||||||
|
self.app.rule_loader.load()
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": updated.to_dict()})
|
||||||
|
|
||||||
|
def handle_delete_rule(self, handler, rule_id: str) -> None:
|
||||||
|
deleted = self.app.rule_store.delete_rule(rule_id)
|
||||||
|
self.app.rule_loader.load()
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": {"deleted": deleted}})
|
||||||
|
|
||||||
|
def handle_enable_rule(self, handler, rule_id: str) -> None:
|
||||||
|
rule = self._set_rule_enabled(rule_id, True)
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": {"enabled": rule.enabled}})
|
||||||
|
|
||||||
|
def handle_disable_rule(self, handler, rule_id: str) -> None:
|
||||||
|
rule = self._set_rule_enabled(rule_id, False)
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": {"enabled": rule.enabled}})
|
||||||
|
|
||||||
|
def _set_rule_enabled(self, rule_id: str, enabled: bool) -> MetricRule:
|
||||||
|
rule = self.app.rule_loader.get_rule(rule_id)
|
||||||
|
if rule is None:
|
||||||
|
raise _APIError(404, 40400, f"rule {rule_id} not found")
|
||||||
|
rule.enabled = enabled
|
||||||
|
self.app.rule_store.update_rule(rule)
|
||||||
|
self.app.rule_loader.load()
|
||||||
|
return rule
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 事件 / 告警查询与管理
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def handle_list_events(self, handler) -> None:
|
||||||
|
q = handler._query()
|
||||||
|
total, items = self.app.event_store.list_events(
|
||||||
|
host_id=q.get("host_id"),
|
||||||
|
rule_id=q.get("rule_id"),
|
||||||
|
status=q.get("status"),
|
||||||
|
severity=q.get("severity"),
|
||||||
|
start=_parse_float(q.get("from")),
|
||||||
|
end=_parse_float(q.get("to")),
|
||||||
|
page=int(q.get("page", "1")),
|
||||||
|
page_size=min(int(q.get("page_size", "20")), 200),
|
||||||
|
)
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": {"total": total, "items": [e.to_dict() for e in items]}})
|
||||||
|
|
||||||
|
def handle_list_alerts(self, handler) -> None:
|
||||||
|
q = handler._query()
|
||||||
|
total, items = self.app.alert_store.list_alerts(
|
||||||
|
severity=q.get("severity"),
|
||||||
|
status=q.get("status"),
|
||||||
|
ack_status=q.get("ack_status"),
|
||||||
|
start=_parse_float(q.get("from")),
|
||||||
|
end=_parse_float(q.get("to")),
|
||||||
|
page=int(q.get("page", "1")),
|
||||||
|
page_size=min(int(q.get("page_size", "20")), 200),
|
||||||
|
)
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": {"total": total, "items": [a.to_dict() for a in items]}})
|
||||||
|
|
||||||
|
def handle_get_alert(self, handler, alert_id: str) -> None:
|
||||||
|
alert = self.app.alert_store.get(alert_id)
|
||||||
|
if alert is None:
|
||||||
|
raise _APIError(404, 40400, f"alert {alert_id} not found")
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": alert.to_dict()})
|
||||||
|
|
||||||
|
def handle_ack_alert(self, handler, alert_id: str) -> None:
|
||||||
|
body = handler._read_json()
|
||||||
|
alert = self.app.alert_store.ack(alert_id, body.get("ack_by") or "system")
|
||||||
|
if alert is None:
|
||||||
|
raise _APIError(404, 40400, f"alert {alert_id} not found")
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": alert.to_dict()})
|
||||||
|
|
||||||
|
def handle_close_alert(self, handler, alert_id: str) -> None:
|
||||||
|
alert = self.app.alert_store.close(alert_id)
|
||||||
|
if alert is None:
|
||||||
|
raise _APIError(404, 40400, f"alert {alert_id} not found")
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": alert.to_dict()})
|
||||||
|
|
||||||
|
def handle_batch_ack(self, handler) -> None:
|
||||||
|
body = handler._read_json()
|
||||||
|
ids = body.get("alert_ids") or []
|
||||||
|
acked = 0
|
||||||
|
for alert_id in ids:
|
||||||
|
if self.app.alert_store.ack(str(alert_id)) is not None:
|
||||||
|
acked += 1
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": {"acked": acked}})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 样本接入(端到端联调)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def handle_ingest(self, handler) -> None:
|
||||||
|
body = handler._read_json()
|
||||||
|
host_id = str(body.get("host_id") or "")
|
||||||
|
if not host_id:
|
||||||
|
raise _APIError(400, 40001, "host_id is required")
|
||||||
|
host_group = body.get("host_group")
|
||||||
|
samples = [MetricSample.from_dict(s) for s in (body.get("samples") or [])]
|
||||||
|
events = self.app.ingest(host_id, host_group, samples)
|
||||||
|
handler._write_json(200, {"code": 0, "message": "ok", "data": {"events": [e.to_dict() for e in events]}})
|
||||||
|
|
||||||
|
|
||||||
|
class _APIError(Exception):
|
||||||
|
def __init__(self, status: int, code: int, message: str) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_float(value: Optional[str]) -> Optional[float]:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def serve(app: DetectorApp, addr: str = "0.0.0.0:8080") -> APIServer:
|
||||||
|
api = APIServer(app, addr)
|
||||||
|
api.serve_forever()
|
||||||
|
return api
|
||||||
124
detector/app.py
Normal file
124
detector/app.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"""应用装配:把规则加载、检测引擎、事件写入、收敛、通知与总线串起来。
|
||||||
|
|
||||||
|
DetectorApp 同时承载 REST API 所需的各 store 引用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .converger import AlertConverger
|
||||||
|
from .engine import DetectionEngine
|
||||||
|
from .models import MetricRule, MetricSample, RuleScope
|
||||||
|
from .notifier import Notifier
|
||||||
|
from .rule_loader import RuleLoader
|
||||||
|
from .storage import (
|
||||||
|
AlertStore,
|
||||||
|
Cache,
|
||||||
|
EventStore,
|
||||||
|
InMemoryMessageBus,
|
||||||
|
MessageBus,
|
||||||
|
RuleStore,
|
||||||
|
create_alert_store,
|
||||||
|
create_bus,
|
||||||
|
create_cache,
|
||||||
|
create_event_store,
|
||||||
|
create_rule_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DetectorApp:
|
||||||
|
def __init__(self, config: Optional[Config] = None) -> None:
|
||||||
|
self.config = config or Config.from_env()
|
||||||
|
self.rule_store: RuleStore = create_rule_store(self.config)
|
||||||
|
self.event_store: EventStore = create_event_store(self.config)
|
||||||
|
self.alert_store: AlertStore = create_alert_store(self.config)
|
||||||
|
self.cache: Cache = create_cache(self.config)
|
||||||
|
self.bus: MessageBus = create_bus(self.config)
|
||||||
|
|
||||||
|
self.rule_loader = RuleLoader(self.rule_store, self.cache, self.config.rule_reload_interval)
|
||||||
|
self.notifier = Notifier.memory()
|
||||||
|
self.converger = AlertConverger(
|
||||||
|
alert_store=self.alert_store,
|
||||||
|
cache=self.cache,
|
||||||
|
bus=self.bus,
|
||||||
|
dedup_window=self.config.dedup_window,
|
||||||
|
aggregate_window=self.config.aggregate_window,
|
||||||
|
alerts_topic=self.config.alerts_topic,
|
||||||
|
notifier=self.notifier,
|
||||||
|
)
|
||||||
|
self.engine = DetectionEngine(
|
||||||
|
rule_loader=self.rule_loader,
|
||||||
|
evaluation_interval=self.config.evaluation_interval,
|
||||||
|
recover_duration=self.config.recover_duration,
|
||||||
|
on_event=self._on_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_event(self, event) -> None:
|
||||||
|
# 事件先落库,再做收敛(收敛可能因去重被抑制)。
|
||||||
|
self.event_store.insert(event)
|
||||||
|
self.converger.handle_event(event)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def start(self) -> None:
|
||||||
|
self.rule_loader.load()
|
||||||
|
self.rule_loader.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.rule_loader.stop()
|
||||||
|
|
||||||
|
def seed_demo_rules(self) -> List[MetricRule]:
|
||||||
|
"""写入示例规则,便于本地联调/演示。"""
|
||||||
|
demos = [
|
||||||
|
MetricRule(
|
||||||
|
rule_id="r-cpu-high",
|
||||||
|
name="CPU 使用率过高",
|
||||||
|
metric="cpu_usage",
|
||||||
|
aggregation="avg",
|
||||||
|
operator="gt",
|
||||||
|
threshold=90.0,
|
||||||
|
for_duration="60s",
|
||||||
|
severity="critical",
|
||||||
|
scope=RuleScope(scope_type="all"),
|
||||||
|
notify_channels=["email", "webhook"],
|
||||||
|
enabled=True,
|
||||||
|
),
|
||||||
|
MetricRule(
|
||||||
|
rule_id="r-mem-high",
|
||||||
|
name="内存使用率过高",
|
||||||
|
metric="mem_used_percent",
|
||||||
|
aggregation="avg",
|
||||||
|
operator="gt",
|
||||||
|
threshold=85.0,
|
||||||
|
for_duration="60s",
|
||||||
|
severity="warning",
|
||||||
|
scope=RuleScope(scope_type="host_group", host_group="web"),
|
||||||
|
notify_channels=["email"],
|
||||||
|
enabled=True,
|
||||||
|
),
|
||||||
|
MetricRule(
|
||||||
|
rule_id="r-disk-full",
|
||||||
|
name="磁盘使用率过高",
|
||||||
|
metric="disk_used_percent",
|
||||||
|
aggregation="avg",
|
||||||
|
operator="gt",
|
||||||
|
threshold=90.0,
|
||||||
|
for_duration="30s",
|
||||||
|
severity="critical",
|
||||||
|
scope=RuleScope(scope_type="all"),
|
||||||
|
notify_channels=["webhook"],
|
||||||
|
enabled=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
created = []
|
||||||
|
for rule in demos:
|
||||||
|
if self.rule_store.get_rule(rule.rule_id) is None:
|
||||||
|
created.append(self.rule_store.create_rule(rule))
|
||||||
|
self.rule_loader.load()
|
||||||
|
return created
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def ingest(self, host_id: str, host_group: Optional[str], samples: List[MetricSample]):
|
||||||
|
"""接收一批样本并驱动检测,返回触发的事件。"""
|
||||||
|
return self.engine.handle_batch(samples, host_id, host_group)
|
||||||
70
detector/config.py
Normal file
70
detector/config.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""运行配置。
|
||||||
|
|
||||||
|
支持从环境变量读取(HMS_ 前缀),未配置时使用安全默认值,保证模块开箱即用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
def _env(name: str, default: str) -> str:
|
||||||
|
return os.environ.get(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
"""检测模块配置。"""
|
||||||
|
|
||||||
|
http_addr: str = "0.0.0.0:8080"
|
||||||
|
|
||||||
|
# 检测引擎
|
||||||
|
evaluation_interval: str = "15s" # 评估周期(PENDING 恢复观察默认时长)
|
||||||
|
recover_duration: str = "15s" # 恢复观察窗口
|
||||||
|
|
||||||
|
# 告警收敛
|
||||||
|
dedup_window: str = "5m" # 去重窗口(同 host+rule 抑制)
|
||||||
|
aggregate_window: str = "1m" # 聚合窗口
|
||||||
|
|
||||||
|
# 规则热加载
|
||||||
|
rule_reload_interval: str = "10s"
|
||||||
|
|
||||||
|
# 存储驱动:memory / mysql / redis(可选依赖未安装时自动回退 memory)
|
||||||
|
rule_store_driver: str = "memory"
|
||||||
|
event_store_driver: str = "memory"
|
||||||
|
alert_store_driver: str = "memory"
|
||||||
|
cache_driver: str = "memory"
|
||||||
|
bus_driver: str = "memory"
|
||||||
|
|
||||||
|
# 中间件连接配置(接入真实中间件时使用)
|
||||||
|
mysql_dsn: str = ""
|
||||||
|
redis_url: str = "redis://127.0.0.1:6379/0"
|
||||||
|
kafka_bootstrap: str = "127.0.0.1:9092"
|
||||||
|
metrics_topic: str = "metrics.samples"
|
||||||
|
alerts_topic: str = "alerts.converged"
|
||||||
|
|
||||||
|
# 是否在启动时写入示例规则(便于本地联调/演示)
|
||||||
|
seed_demo_rules: bool = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "Config":
|
||||||
|
return cls(
|
||||||
|
http_addr=_env("HMS_HTTP_ADDR", "0.0.0.0:8080"),
|
||||||
|
evaluation_interval=_env("HMS_EVALUATION_INTERVAL", "15s"),
|
||||||
|
recover_duration=_env("HMS_RECOVER_DURATION", "15s"),
|
||||||
|
dedup_window=_env("HMS_DEDUP_WINDOW", "5m"),
|
||||||
|
aggregate_window=_env("HMS_AGGREGATE_WINDOW", "1m"),
|
||||||
|
rule_reload_interval=_env("HMS_RULE_RELOAD_INTERVAL", "10s"),
|
||||||
|
rule_store_driver=_env("HMS_RULE_STORE_DRIVER", "memory"),
|
||||||
|
event_store_driver=_env("HMS_EVENT_STORE_DRIVER", "memory"),
|
||||||
|
alert_store_driver=_env("HMS_ALERT_STORE_DRIVER", "memory"),
|
||||||
|
cache_driver=_env("HMS_CACHE_DRIVER", "memory"),
|
||||||
|
bus_driver=_env("HMS_BUS_DRIVER", "memory"),
|
||||||
|
mysql_dsn=_env("HMS_MYSQL_DSN", ""),
|
||||||
|
redis_url=_env("HMS_REDIS_URL", "redis://127.0.0.1:6379/0"),
|
||||||
|
kafka_bootstrap=_env("HMS_KAFKA_BOOTSTRAP", "127.0.0.1:9092"),
|
||||||
|
metrics_topic=_env("HMS_METRICS_TOPIC", "metrics.samples"),
|
||||||
|
alerts_topic=_env("HMS_ALERTS_TOPIC", "alerts.converged"),
|
||||||
|
seed_demo_rules=_env("HMS_SEED_DEMO_RULES", "true").lower() in ("1", "true", "yes"),
|
||||||
|
)
|
||||||
101
detector/converger.py
Normal file
101
detector/converger.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""告警收敛:去重 + 聚合。
|
||||||
|
|
||||||
|
- 去重:同一 (host_id, rule_id) 在 dedup_window 内不重复产生同类新告警
|
||||||
|
(Redis SET key NX EX dedup_window,InMemory 语义等价)。
|
||||||
|
- 聚合:同一 aggregate_key(scope/rule/severity + 时间桶)在 aggregate_window
|
||||||
|
内多主机同类告警合并为一条,aggregated_count 累加,明细 hosts 列表合并。
|
||||||
|
- 收敛后的 alert 写入 AlertStore,并发布到 alerts.converged 总线,由通知分发器消费。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from .models import Alert, Event, parse_duration
|
||||||
|
from .storage import AlertStore, Cache, MessageBus
|
||||||
|
|
||||||
|
DEDUP_PREFIX = "hms:dedup:"
|
||||||
|
|
||||||
|
|
||||||
|
class AlertConverger:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
alert_store: AlertStore,
|
||||||
|
cache: Cache,
|
||||||
|
bus: MessageBus,
|
||||||
|
dedup_window: str = "5m",
|
||||||
|
aggregate_window: str = "1m",
|
||||||
|
alerts_topic: str = "alerts.converged",
|
||||||
|
host_group_resolver: Optional[Callable[[str], str]] = None,
|
||||||
|
notifier: Optional[object] = None,
|
||||||
|
) -> None:
|
||||||
|
self.alert_store = alert_store
|
||||||
|
self.cache = cache
|
||||||
|
self.bus = bus
|
||||||
|
self.dedup_window = parse_duration(dedup_window) or 300
|
||||||
|
self.aggregate_window = parse_duration(aggregate_window) or 60
|
||||||
|
self.alerts_topic = alerts_topic
|
||||||
|
self.host_group_resolver = host_group_resolver or (lambda host_id: "all")
|
||||||
|
self.notifier = notifier
|
||||||
|
|
||||||
|
def handle_event(self, event: Event) -> Optional[Alert]:
|
||||||
|
if event.status == "firing":
|
||||||
|
return self._handle_firing(event)
|
||||||
|
if event.status == "resolved":
|
||||||
|
return self._handle_resolved(event)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_firing(self, event: Event) -> Optional[Alert]:
|
||||||
|
dedup_key = f"{event.host_id}:{event.rule_id}"
|
||||||
|
if not self.cache.set_nx(DEDUP_PREFIX + dedup_key, "1", ttl=self.dedup_window):
|
||||||
|
# 去重窗口内重复事件,抑制新告警
|
||||||
|
return None
|
||||||
|
|
||||||
|
bucket = int(event.fired_at // self.aggregate_window)
|
||||||
|
group = self.host_group_resolver(event.host_id)
|
||||||
|
aggregate_key = f"{group}:{event.rule_id}:{event.severity}:{bucket}"
|
||||||
|
|
||||||
|
alert = Alert(
|
||||||
|
alert_id=f"a-{uuid.uuid4().hex[:16]}",
|
||||||
|
dedup_key=dedup_key,
|
||||||
|
aggregate_key=aggregate_key,
|
||||||
|
severity=event.severity,
|
||||||
|
status="firing",
|
||||||
|
title=self._title(event),
|
||||||
|
detail={
|
||||||
|
"hosts": [event.host_id],
|
||||||
|
"current": event.agg_value,
|
||||||
|
"threshold": event.threshold,
|
||||||
|
"metric": event.metric,
|
||||||
|
"rule_id": event.rule_id,
|
||||||
|
},
|
||||||
|
count=1,
|
||||||
|
first_at=event.fired_at,
|
||||||
|
last_at=event.fired_at,
|
||||||
|
)
|
||||||
|
stored = self.alert_store.upsert_firing(alert)
|
||||||
|
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))
|
||||||
|
return stored
|
||||||
|
|
||||||
|
def _handle_resolved(self, event: Event) -> Optional[Alert]:
|
||||||
|
dedup_key = f"{event.host_id}:{event.rule_id}"
|
||||||
|
# 恢复事件允许后续再次触发去重键失效
|
||||||
|
self.cache.delete(DEDUP_PREFIX + dedup_key)
|
||||||
|
alert = self.alert_store.resolve_by_dedup_key(dedup_key, event.fired_at)
|
||||||
|
if alert is not None:
|
||||||
|
self._publish(alert)
|
||||||
|
return alert
|
||||||
|
|
||||||
|
def _publish(self, alert: Alert) -> None:
|
||||||
|
self.bus.publish(self.alerts_topic, alert.aggregate_key, alert.to_dict())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _title(event: Event) -> str:
|
||||||
|
return f"{event.metric} {event.operator} {event.threshold} ({event.status})"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _channels(event: Event) -> List[str]:
|
||||||
|
return []
|
||||||
252
detector/engine.py
Normal file
252
detector/engine.py
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
"""检测引擎:滑动窗口聚合 + 状态机(OK → FIRING → PENDING → OK)。
|
||||||
|
|
||||||
|
对每个 (host_id, rule_id) 维护:
|
||||||
|
- 滑动窗口(保存 for_duration 窗口内的样本,用于计算聚合值);
|
||||||
|
- 状态机(追踪条件持续满足/不满足时间,抑制瞬时毛刺)。
|
||||||
|
|
||||||
|
状态跃迁与 architecture.md 5.2.3 对齐:
|
||||||
|
- OK → FIRING:条件持续满足 ForDuration,触发 firing 事件;
|
||||||
|
- FIRING → PENDING:最近一次评估不再满足,进入恢复观察;
|
||||||
|
- PENDING → OK:观察期(recover_duration)内持续不满足,触发 resolved 事件;
|
||||||
|
- PENDING → FIRING:观察期内再次满足,回到 FIRING(不重复触发事件)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from .models import Event, MetricRule, MetricSample, parse_duration
|
||||||
|
from .rule_loader import RuleLoader
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_value(values: List[float], aggregation: str) -> float:
|
||||||
|
if not values:
|
||||||
|
return 0.0
|
||||||
|
agg = (aggregation or "avg").lower()
|
||||||
|
if agg == "avg":
|
||||||
|
return sum(values) / len(values)
|
||||||
|
if agg == "min":
|
||||||
|
return min(values)
|
||||||
|
if agg == "max":
|
||||||
|
return max(values)
|
||||||
|
if agg == "sum":
|
||||||
|
return sum(values)
|
||||||
|
if agg == "last":
|
||||||
|
return values[-1]
|
||||||
|
raise ValueError(f"unknown aggregation: {aggregation}")
|
||||||
|
|
||||||
|
|
||||||
|
def compare(value: float, operator: str, threshold: float, threshold2: Optional[float] = None) -> bool:
|
||||||
|
op = (operator or "gt").lower()
|
||||||
|
eps = 1e-9
|
||||||
|
if op == "gt":
|
||||||
|
return value > threshold
|
||||||
|
if op == "gte":
|
||||||
|
return value >= threshold
|
||||||
|
if op == "lt":
|
||||||
|
return value < threshold
|
||||||
|
if op == "lte":
|
||||||
|
return value <= threshold
|
||||||
|
if op == "eq":
|
||||||
|
return abs(value - threshold) <= eps
|
||||||
|
if op == "neq":
|
||||||
|
return abs(value - threshold) > eps
|
||||||
|
if op == "between":
|
||||||
|
lo = threshold
|
||||||
|
hi = threshold2 if threshold2 is not None else threshold
|
||||||
|
if lo > hi:
|
||||||
|
lo, hi = hi, lo
|
||||||
|
return lo <= value <= hi
|
||||||
|
raise ValueError(f"unknown operator: {operator}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _State:
|
||||||
|
status: str = "ok" # ok | firing | pending
|
||||||
|
satisfied_since: Optional[float] = None
|
||||||
|
not_satisfied_since: Optional[float] = None
|
||||||
|
last_value: float = 0.0
|
||||||
|
last_eval: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class SlidingWindow:
|
||||||
|
"""按时间衰减的样本窗口。"""
|
||||||
|
|
||||||
|
def __init__(self, max_age: float) -> None:
|
||||||
|
self.max_age = max_age
|
||||||
|
self.samples: deque = deque() # (timestamp, value)
|
||||||
|
|
||||||
|
def add(self, timestamp: float, value: float) -> None:
|
||||||
|
self.samples.append((timestamp, value))
|
||||||
|
self._prune(timestamp)
|
||||||
|
|
||||||
|
def _prune(self, now: float) -> None:
|
||||||
|
while self.samples and (now - self.samples[0][0]) > self.max_age:
|
||||||
|
self.samples.popleft()
|
||||||
|
|
||||||
|
def values(self) -> List[float]:
|
||||||
|
return [v for _, v in self.samples]
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self.samples.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionEngine:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
rule_loader: RuleLoader,
|
||||||
|
evaluation_interval: str = "15s",
|
||||||
|
recover_duration: str = "15s",
|
||||||
|
on_event: Optional[Callable[[Event], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
self.rule_loader = rule_loader
|
||||||
|
self.evaluation_interval = parse_duration(evaluation_interval) or 15
|
||||||
|
self.recover_duration = parse_duration(recover_duration) or self.evaluation_interval
|
||||||
|
self.on_event = on_event
|
||||||
|
self._windows: Dict[Tuple[str, str], SlidingWindow] = {}
|
||||||
|
self._states: Dict[Tuple[str, str], _State] = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 入口
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def handle(
|
||||||
|
self,
|
||||||
|
sample: MetricSample,
|
||||||
|
host_id: str,
|
||||||
|
host_group: Optional[str] = None,
|
||||||
|
now: Optional[float] = None,
|
||||||
|
) -> List[Event]:
|
||||||
|
"""处理单条样本,返回本次触发的所有事件。"""
|
||||||
|
now = sample.timestamp if now is None else now
|
||||||
|
events: List[Event] = []
|
||||||
|
rules = self.rule_loader.match(sample, host_id, host_group)
|
||||||
|
with self._lock:
|
||||||
|
for rule in rules:
|
||||||
|
event = self._evaluate(host_id, rule, sample, now)
|
||||||
|
if event is not None:
|
||||||
|
events.append(event)
|
||||||
|
if self.on_event:
|
||||||
|
for event in events:
|
||||||
|
self.on_event(event)
|
||||||
|
return events
|
||||||
|
|
||||||
|
def handle_batch(
|
||||||
|
self,
|
||||||
|
samples: List[MetricSample],
|
||||||
|
host_id: str,
|
||||||
|
host_group: Optional[str] = None,
|
||||||
|
) -> List[Event]:
|
||||||
|
events: List[Event] = []
|
||||||
|
for sample in samples:
|
||||||
|
events.extend(self.handle(sample, host_id, host_group))
|
||||||
|
return events
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 单规则评估
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def _evaluate(
|
||||||
|
self,
|
||||||
|
host_id: str,
|
||||||
|
rule: MetricRule,
|
||||||
|
sample: MetricSample,
|
||||||
|
now: float,
|
||||||
|
) -> Optional[Event]:
|
||||||
|
key = (host_id, rule.rule_id)
|
||||||
|
for_duration = parse_duration(rule.for_duration) or 60
|
||||||
|
|
||||||
|
window = self._windows.get(key)
|
||||||
|
if window is None or window.max_age != for_duration:
|
||||||
|
window = SlidingWindow(for_duration)
|
||||||
|
self._windows[key] = window
|
||||||
|
window.add(sample.timestamp, sample.value)
|
||||||
|
|
||||||
|
state = self._states.get(key)
|
||||||
|
if state is None:
|
||||||
|
state = _State()
|
||||||
|
self._states[key] = state
|
||||||
|
|
||||||
|
value = aggregate_value(window.values(), rule.aggregation)
|
||||||
|
satisfied = compare(value, rule.operator, rule.threshold, rule.threshold2)
|
||||||
|
state.last_value = value
|
||||||
|
state.last_eval = now
|
||||||
|
|
||||||
|
event: Optional[Event] = None
|
||||||
|
if state.status == "ok":
|
||||||
|
if satisfied:
|
||||||
|
if state.satisfied_since is None:
|
||||||
|
state.satisfied_since = now
|
||||||
|
if now - state.satisfied_since >= for_duration:
|
||||||
|
state.status = "firing"
|
||||||
|
state.not_satisfied_since = None
|
||||||
|
event = self._make_event(host_id, rule, sample, value, "firing", now)
|
||||||
|
else:
|
||||||
|
state.satisfied_since = None
|
||||||
|
elif state.status == "firing":
|
||||||
|
if not satisfied:
|
||||||
|
state.status = "pending"
|
||||||
|
state.not_satisfied_since = now
|
||||||
|
elif state.status == "pending":
|
||||||
|
if satisfied:
|
||||||
|
state.status = "firing"
|
||||||
|
state.not_satisfied_since = None
|
||||||
|
# 回到 FIRING 不重复告警(仅更新计数),与设计一致
|
||||||
|
else:
|
||||||
|
if state.not_satisfied_since is None:
|
||||||
|
state.not_satisfied_since = now
|
||||||
|
if now - state.not_satisfied_since >= self.recover_duration:
|
||||||
|
state.status = "ok"
|
||||||
|
state.satisfied_since = None
|
||||||
|
event = self._make_event(host_id, rule, sample, value, "resolved", now)
|
||||||
|
|
||||||
|
self._states[key] = state
|
||||||
|
return event
|
||||||
|
|
||||||
|
def _make_event(
|
||||||
|
self,
|
||||||
|
host_id: str,
|
||||||
|
rule: MetricRule,
|
||||||
|
sample: MetricSample,
|
||||||
|
value: float,
|
||||||
|
status: str,
|
||||||
|
now: float,
|
||||||
|
) -> Event:
|
||||||
|
return Event(
|
||||||
|
event_id=f"e-{uuid.uuid4().hex[:16]}",
|
||||||
|
host_id=host_id,
|
||||||
|
rule_id=rule.rule_id,
|
||||||
|
metric=rule.metric,
|
||||||
|
agg_value=value,
|
||||||
|
threshold=rule.threshold,
|
||||||
|
operator=rule.operator,
|
||||||
|
status=status,
|
||||||
|
severity=rule.severity,
|
||||||
|
fired_at=now,
|
||||||
|
labels=dict(sample.labels),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 状态查询(测试/可观测)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def state_of(self, host_id: str, rule_id: str) -> Optional[Dict[str, object]]:
|
||||||
|
key = (host_id, rule_id)
|
||||||
|
with self._lock:
|
||||||
|
state = self._states.get(key)
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"status": state.status,
|
||||||
|
"satisfied_since": state.satisfied_since,
|
||||||
|
"not_satisfied_since": state.not_satisfied_since,
|
||||||
|
"last_value": state.last_value,
|
||||||
|
"last_eval": state.last_eval,
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._windows.clear()
|
||||||
|
self._states.clear()
|
||||||
238
detector/models.py
Normal file
238
detector/models.py
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
"""领域模型:指标样本、阈值规则、作用范围、事件与告警。
|
||||||
|
|
||||||
|
字段命名与 docs/01-design 的 architecture.md / database-design.md / api-design.md 对齐;
|
||||||
|
对外 JSON 使用 camelCase,内部 Python 使用 snake_case。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def parse_duration(value: Optional[str]) -> int:
|
||||||
|
"""将 '60s' / '5m' / '1h' / '1h30m' 等时长解析为秒。
|
||||||
|
|
||||||
|
纯数字按秒处理;空值返回 0。
|
||||||
|
"""
|
||||||
|
if value is None or value == "":
|
||||||
|
return 0
|
||||||
|
text = str(value).strip().lower()
|
||||||
|
if text.isdigit():
|
||||||
|
return int(text)
|
||||||
|
|
||||||
|
total = 0.0
|
||||||
|
num = ""
|
||||||
|
for ch in text:
|
||||||
|
if ch.isdigit() or ch == ".":
|
||||||
|
num += ch
|
||||||
|
elif ch in "smhd":
|
||||||
|
if not num:
|
||||||
|
raise ValueError(f"invalid duration: {value!r}")
|
||||||
|
n = float(num)
|
||||||
|
if ch == "s":
|
||||||
|
total += n
|
||||||
|
elif ch == "m":
|
||||||
|
total += n * 60
|
||||||
|
elif ch == "h":
|
||||||
|
total += n * 3600
|
||||||
|
elif ch == "d":
|
||||||
|
total += n * 86400
|
||||||
|
num = ""
|
||||||
|
else:
|
||||||
|
raise ValueError(f"invalid duration: {value!r}")
|
||||||
|
if num:
|
||||||
|
total += float(num)
|
||||||
|
return int(total)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MetricSample:
|
||||||
|
"""规范化后的单条指标样本(对应 metrics.samples 消息)。"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
value: float
|
||||||
|
timestamp: float
|
||||||
|
labels: Dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "MetricSample":
|
||||||
|
return cls(
|
||||||
|
name=str(data["name"]),
|
||||||
|
value=float(data["value"]),
|
||||||
|
timestamp=float(data.get("timestamp", 0)),
|
||||||
|
labels=dict(data.get("labels") or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"value": self.value,
|
||||||
|
"timestamp": self.timestamp,
|
||||||
|
"labels": self.labels,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RuleScope:
|
||||||
|
"""规则作用范围(对应 rule_scope 表)。"""
|
||||||
|
|
||||||
|
scope_type: str = "all" # all | host_ids | host_group
|
||||||
|
host_ids: List[str] = field(default_factory=list)
|
||||||
|
host_group: str = ""
|
||||||
|
service: str = ""
|
||||||
|
|
||||||
|
def matches(self, host_id: str, host_group: Optional[str], service: Optional[str]) -> bool:
|
||||||
|
if self.scope_type == "all":
|
||||||
|
return True
|
||||||
|
if self.scope_type == "host_ids":
|
||||||
|
return host_id in self.host_ids
|
||||||
|
if self.scope_type == "host_group":
|
||||||
|
if self.host_group and host_group and self.host_group == host_group:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"scope_type": self.scope_type,
|
||||||
|
"host_ids": list(self.host_ids),
|
||||||
|
"host_group": self.host_group,
|
||||||
|
"service": self.service,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "RuleScope":
|
||||||
|
if not data:
|
||||||
|
return cls()
|
||||||
|
return cls(
|
||||||
|
scope_type=str(data.get("scope_type") or "all"),
|
||||||
|
host_ids=[str(x) for x in (data.get("host_ids") or [])],
|
||||||
|
host_group=str(data.get("host_group") or ""),
|
||||||
|
service=str(data.get("service") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MetricRule:
|
||||||
|
"""阈值规则(对应 metric_rule 表)。"""
|
||||||
|
|
||||||
|
rule_id: str
|
||||||
|
name: str
|
||||||
|
metric: str
|
||||||
|
aggregation: str = "avg" # avg | min | max | sum | last
|
||||||
|
operator: str = "gt" # gt | gte | lt | lte | eq | neq | between
|
||||||
|
threshold: float = 0.0
|
||||||
|
threshold2: Optional[float] = None
|
||||||
|
for_duration: str = "60s"
|
||||||
|
severity: str = "warning"
|
||||||
|
scope: RuleScope = field(default_factory=RuleScope)
|
||||||
|
labels: Dict[str, str] = field(default_factory=dict)
|
||||||
|
notify_channels: List[str] = field(default_factory=list)
|
||||||
|
enabled: bool = True
|
||||||
|
version: int = 0
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"rule_id": self.rule_id,
|
||||||
|
"name": self.name,
|
||||||
|
"metric": self.metric,
|
||||||
|
"aggregation": self.aggregation,
|
||||||
|
"operator": self.operator,
|
||||||
|
"threshold": self.threshold,
|
||||||
|
"threshold2": self.threshold2,
|
||||||
|
"for_duration": self.for_duration,
|
||||||
|
"severity": self.severity,
|
||||||
|
"scope": self.scope.to_dict(),
|
||||||
|
"labels": dict(self.labels),
|
||||||
|
"notify_channels": list(self.notify_channels),
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"version": self.version,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "MetricRule":
|
||||||
|
return cls(
|
||||||
|
rule_id=str(data["rule_id"]),
|
||||||
|
name=str(data.get("name") or ""),
|
||||||
|
metric=str(data.get("metric") or ""),
|
||||||
|
aggregation=str(data.get("aggregation") or "avg"),
|
||||||
|
operator=str(data.get("operator") or "gt"),
|
||||||
|
threshold=float(data.get("threshold") or 0.0),
|
||||||
|
threshold2=float(data["threshold2"]) if data.get("threshold2") is not None else None,
|
||||||
|
for_duration=str(data.get("for_duration") or "60s"),
|
||||||
|
severity=str(data.get("severity") or "warning"),
|
||||||
|
scope=RuleScope.from_dict(data.get("scope")),
|
||||||
|
labels={str(k): str(v) for k, v in (data.get("labels") or {}).items()},
|
||||||
|
notify_channels=[str(x) for x in (data.get("notify_channels") or [])],
|
||||||
|
enabled=bool(data.get("enabled", True)),
|
||||||
|
version=int(data.get("version") or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Event:
|
||||||
|
"""检测事件(对应 event 表)。"""
|
||||||
|
|
||||||
|
event_id: str
|
||||||
|
host_id: str
|
||||||
|
rule_id: str
|
||||||
|
metric: str
|
||||||
|
agg_value: float
|
||||||
|
threshold: float
|
||||||
|
operator: str
|
||||||
|
status: str # firing | resolved
|
||||||
|
severity: str
|
||||||
|
fired_at: float
|
||||||
|
labels: Dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"event_id": self.event_id,
|
||||||
|
"host_id": self.host_id,
|
||||||
|
"rule_id": self.rule_id,
|
||||||
|
"metric": self.metric,
|
||||||
|
"agg_value": self.agg_value,
|
||||||
|
"threshold": self.threshold,
|
||||||
|
"operator": self.operator,
|
||||||
|
"status": self.status,
|
||||||
|
"severity": self.severity,
|
||||||
|
"fired_at": self.fired_at,
|
||||||
|
"labels": self.labels,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Alert:
|
||||||
|
"""收敛后告警(对应 alert 表)。"""
|
||||||
|
|
||||||
|
alert_id: str
|
||||||
|
dedup_key: str
|
||||||
|
aggregate_key: str
|
||||||
|
severity: str
|
||||||
|
status: str # firing | resolved
|
||||||
|
title: str = ""
|
||||||
|
detail: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
count: int = 1
|
||||||
|
first_at: float = 0.0
|
||||||
|
last_at: float = 0.0
|
||||||
|
ack_status: str = "open"
|
||||||
|
ack_by: Optional[str] = None
|
||||||
|
ack_at: Optional[float] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"alert_id": self.alert_id,
|
||||||
|
"dedup_key": self.dedup_key,
|
||||||
|
"aggregate_key": self.aggregate_key,
|
||||||
|
"severity": self.severity,
|
||||||
|
"status": self.status,
|
||||||
|
"title": self.title,
|
||||||
|
"detail": dict(self.detail),
|
||||||
|
"count": self.count,
|
||||||
|
"first_at": self.first_at,
|
||||||
|
"last_at": self.last_at,
|
||||||
|
"ack_status": self.ack_status,
|
||||||
|
"ack_by": self.ack_by,
|
||||||
|
"ack_at": self.ack_at,
|
||||||
|
}
|
||||||
86
detector/notifier.py
Normal file
86
detector/notifier.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"""通知渠道封装。
|
||||||
|
|
||||||
|
对 email / webhook / dingtalk / wechat 等渠道做统一封装;真实发送通过
|
||||||
|
urllib 完成(或按需扩展 SDK),默认 InMemorySender 记录发送历史,便于测试。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from .models import Alert
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelSender:
|
||||||
|
"""通知渠道发送器接口。"""
|
||||||
|
|
||||||
|
def send(self, alert: Alert, target: Optional[str] = None) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class InMemorySender(ChannelSender):
|
||||||
|
"""记录发送历史,不真正外呼。"""
|
||||||
|
|
||||||
|
def __init__(self, channel: str) -> None:
|
||||||
|
self.channel = channel
|
||||||
|
self.sent: List[Dict[str, Any]] = []
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def send(self, alert: Alert, target: Optional[str] = None) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
self.sent.append({"channel": self.channel, "target": target, "alert_id": alert.alert_id})
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookSender(ChannelSender):
|
||||||
|
"""通用 Webhook(POST JSON)。"""
|
||||||
|
|
||||||
|
def __init__(self, url: str) -> None:
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
def send(self, alert: Alert, target: Optional[str] = None) -> bool:
|
||||||
|
payload = json.dumps(alert.to_dict()).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
target or self.url,
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
|
||||||
|
return 200 <= resp.status < 300
|
||||||
|
|
||||||
|
|
||||||
|
class Notifier:
|
||||||
|
"""通知分发器:按告警渠道列表投递。"""
|
||||||
|
|
||||||
|
def __init__(self, senders: Optional[Dict[str, ChannelSender]] = None) -> None:
|
||||||
|
self.senders = senders or {}
|
||||||
|
self.sent: List[Dict[str, Any]] = []
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def register(self, channel: str, sender: ChannelSender) -> None:
|
||||||
|
self.senders[channel] = sender
|
||||||
|
|
||||||
|
def notify(self, alert: Alert, channels: Optional[List[str]] = None) -> List[str]:
|
||||||
|
"""发送告警,返回成功渠道列表。"""
|
||||||
|
channels = channels or list(self.senders.keys())
|
||||||
|
delivered: List[str] = []
|
||||||
|
for channel in channels:
|
||||||
|
sender = self.senders.get(channel)
|
||||||
|
if sender is None:
|
||||||
|
continue
|
||||||
|
ok = sender.send(alert)
|
||||||
|
with self._lock:
|
||||||
|
self.sent.append({"channel": channel, "alert_id": alert.alert_id, "ok": ok})
|
||||||
|
if ok:
|
||||||
|
delivered.append(channel)
|
||||||
|
return delivered
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def memory(cls, channels: Optional[List[str]] = None) -> "Notifier":
|
||||||
|
"""构造内存版 Notifier,便于本地运行/测试。"""
|
||||||
|
channels = channels or ["email", "webhook", "dingtalk", "wechat"]
|
||||||
|
return cls({c: InMemorySender(c) for c in channels})
|
||||||
101
detector/rule_loader.py
Normal file
101
detector/rule_loader.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""规则加载与缓存。
|
||||||
|
|
||||||
|
- 从 RuleStore(MySQL)全量加载规则;
|
||||||
|
- 写入 Cache(Redis)作为热缓存(hms:rule:cache:{rule_id}、hms:rule:version);
|
||||||
|
- 后台线程按 reload_interval 轮询版本号,版本变化则热加载;
|
||||||
|
- 提供 match():按 metric / scope(host_id、host_group、service) 匹配启用的规则。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from .models import MetricRule, MetricSample, parse_duration
|
||||||
|
from .storage import Cache, RuleStore
|
||||||
|
|
||||||
|
RULE_CACHE_PREFIX = "hms:rule:cache:"
|
||||||
|
RULE_VERSION_KEY = "hms:rule:version"
|
||||||
|
|
||||||
|
|
||||||
|
class RuleLoader:
|
||||||
|
def __init__(self, store: RuleStore, cache: Cache, reload_interval: str = "10s") -> None:
|
||||||
|
self._store = store
|
||||||
|
self._cache = cache
|
||||||
|
self._reload_interval = parse_duration(reload_interval) or 10
|
||||||
|
self._rules: Dict[str, MetricRule] = {}
|
||||||
|
self._version = 0
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 加载与热更新
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def load(self) -> int:
|
||||||
|
"""全量加载规则,返回当前版本号。"""
|
||||||
|
rules = self._store.list_rules()
|
||||||
|
new_map = {r.rule_id: r for r in rules}
|
||||||
|
version = self._store.get_version()
|
||||||
|
with self._lock:
|
||||||
|
self._rules = new_map
|
||||||
|
self._version = version
|
||||||
|
for rule in rules:
|
||||||
|
self._cache.set(RULE_CACHE_PREFIX + rule.rule_id, json.dumps(rule.to_dict()), ttl=600)
|
||||||
|
self._cache.set(RULE_VERSION_KEY, str(version))
|
||||||
|
return version
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""启动热加载后台线程。"""
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._thread = threading.Thread(target=self._run, name="rule-hot-reload", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
current = self._store.get_version()
|
||||||
|
if current != self._version:
|
||||||
|
self.load()
|
||||||
|
except Exception: # pragma: no cover - 日志由上层处理
|
||||||
|
pass
|
||||||
|
self._stop_event.wait(self._reload_interval)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 查询与匹配
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def get_rules(self) -> List[MetricRule]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._rules.values())
|
||||||
|
|
||||||
|
def get_rule(self, rule_id: str) -> Optional[MetricRule]:
|
||||||
|
with self._lock:
|
||||||
|
return self._rules.get(rule_id)
|
||||||
|
|
||||||
|
def match(
|
||||||
|
self,
|
||||||
|
sample: MetricSample,
|
||||||
|
host_id: str,
|
||||||
|
host_group: Optional[str] = None,
|
||||||
|
service: Optional[str] = None,
|
||||||
|
) -> List[MetricRule]:
|
||||||
|
"""返回匹配该样本的启用规则。"""
|
||||||
|
matched: List[MetricRule] = []
|
||||||
|
service = service or sample.labels.get("service")
|
||||||
|
for rule in self.get_rules():
|
||||||
|
if not rule.enabled:
|
||||||
|
continue
|
||||||
|
if rule.metric != sample.name:
|
||||||
|
continue
|
||||||
|
if not rule.scope.matches(host_id, host_group, service):
|
||||||
|
continue
|
||||||
|
matched.append(rule)
|
||||||
|
return matched
|
||||||
637
detector/storage.py
Normal file
637
detector/storage.py
Normal file
@ -0,0 +1,637 @@
|
|||||||
|
"""存储抽象层。
|
||||||
|
|
||||||
|
- RuleStore / EventStore / AlertStore:MySQL 语义(准),默认 InMemory 实现;
|
||||||
|
- Cache:Redis 语义(get/set NX/INCR/EXPIRE),默认 InMemory 实现;
|
||||||
|
- MessageBus:Kafka 语义(publish),默认 InMemory 实现。
|
||||||
|
|
||||||
|
真实中间件适配器放在同文件末尾,采用惰性导入(可选依赖),未安装驱动时给出
|
||||||
|
清晰错误;测试与本地运行均使用 InMemory 实现,保证零依赖可编译、可运行。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from .models import Alert, Event, MetricRule
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 接口定义
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class RuleStore(ABC):
|
||||||
|
"""规则仓库(Source of Truth:MySQL metric_rule + rule_scope)。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_rules(self) -> List[MetricRule]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_rule(self, rule_id: str) -> Optional[MetricRule]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_rule(self, rule: MetricRule) -> MetricRule:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def update_rule(self, rule: MetricRule) -> MetricRule:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete_rule(self, rule_id: str) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_version(self) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class EventStore(ABC):
|
||||||
|
"""事件仓库(MySQL event 表)。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def insert(self, event: Event) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_events(
|
||||||
|
self,
|
||||||
|
host_id: Optional[str] = None,
|
||||||
|
rule_id: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
severity: Optional[str] = None,
|
||||||
|
start: Optional[float] = None,
|
||||||
|
end: Optional[float] = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> Tuple[int, List[Event]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class AlertStore(ABC):
|
||||||
|
"""告警仓库(MySQL alert 表)。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def upsert_firing(self, alert: Alert) -> Alert:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get(self, alert_id: str) -> Optional[Alert]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_alerts(
|
||||||
|
self,
|
||||||
|
severity: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
ack_status: Optional[str] = None,
|
||||||
|
start: Optional[float] = None,
|
||||||
|
end: Optional[float] = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> Tuple[int, List[Alert]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def ack(self, alert_id: str, ack_by: str = "system") -> Optional[Alert]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def close(self, alert_id: str) -> Optional[Alert]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def resolve_by_dedup_key(self, dedup_key: str, at: float) -> Optional[Alert]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class Cache(ABC):
|
||||||
|
"""缓存抽象(Redis 语义子集)。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get(self, key: str) -> Optional[str]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_nx(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def incr(self, key: str, ttl: Optional[int] = None) -> int:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete(self, key: str) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class MessageBus(ABC):
|
||||||
|
"""消息总线抽象(Kafka 语义子集)。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def publish(self, topic: str, key: str, value: Dict[str, Any]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# InMemory 实现(线程安全)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class InMemoryRuleStore(RuleStore):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._rules: Dict[str, MetricRule] = {}
|
||||||
|
self._version = 0
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def list_rules(self) -> List[MetricRule]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._rules.values())
|
||||||
|
|
||||||
|
def get_rule(self, rule_id: str) -> Optional[MetricRule]:
|
||||||
|
with self._lock:
|
||||||
|
return self._rules.get(rule_id)
|
||||||
|
|
||||||
|
def create_rule(self, rule: MetricRule) -> MetricRule:
|
||||||
|
with self._lock:
|
||||||
|
if rule.rule_id in self._rules:
|
||||||
|
raise ValueError(f"rule {rule.rule_id} already exists")
|
||||||
|
rule.version = rule.version + 1
|
||||||
|
self._rules[rule.rule_id] = rule
|
||||||
|
self._version += 1
|
||||||
|
return rule
|
||||||
|
|
||||||
|
def update_rule(self, rule: MetricRule) -> MetricRule:
|
||||||
|
with self._lock:
|
||||||
|
if rule.rule_id not in self._rules:
|
||||||
|
raise ValueError(f"rule {rule.rule_id} not found")
|
||||||
|
rule.version = self._rules[rule.rule_id].version + 1
|
||||||
|
self._rules[rule.rule_id] = rule
|
||||||
|
self._version += 1
|
||||||
|
return rule
|
||||||
|
|
||||||
|
def delete_rule(self, rule_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
if rule_id not in self._rules:
|
||||||
|
return False
|
||||||
|
del self._rules[rule_id]
|
||||||
|
self._version += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_version(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return self._version
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryEventStore(EventStore):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._events: List[Event] = []
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def insert(self, event: Event) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._events.append(event)
|
||||||
|
|
||||||
|
def list_events(
|
||||||
|
self,
|
||||||
|
host_id: Optional[str] = None,
|
||||||
|
rule_id: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
severity: Optional[str] = None,
|
||||||
|
start: Optional[float] = None,
|
||||||
|
end: Optional[float] = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> Tuple[int, List[Event]]:
|
||||||
|
with self._lock:
|
||||||
|
items = [e for e in self._events if self._match_event(e, host_id, rule_id, status, severity, start, end)]
|
||||||
|
items.sort(key=lambda e: e.fired_at, reverse=True)
|
||||||
|
total = len(items)
|
||||||
|
start_idx = (page - 1) * page_size
|
||||||
|
return total, items[start_idx : start_idx + page_size]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _match_event(
|
||||||
|
e: Event,
|
||||||
|
host_id: Optional[str],
|
||||||
|
rule_id: Optional[str],
|
||||||
|
status: Optional[str],
|
||||||
|
severity: Optional[str],
|
||||||
|
start: Optional[float],
|
||||||
|
end: Optional[float],
|
||||||
|
) -> bool:
|
||||||
|
if host_id and e.host_id != host_id:
|
||||||
|
return False
|
||||||
|
if rule_id and e.rule_id != rule_id:
|
||||||
|
return False
|
||||||
|
if status and e.status != status:
|
||||||
|
return False
|
||||||
|
if severity and e.severity != severity:
|
||||||
|
return False
|
||||||
|
if start is not None and e.fired_at < start:
|
||||||
|
return False
|
||||||
|
if end is not None and e.fired_at > end:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryAlertStore(AlertStore):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._alerts: Dict[str, Alert] = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def upsert_firing(self, alert: Alert) -> Alert:
|
||||||
|
with self._lock:
|
||||||
|
# 同 aggregate_key 且仍在 firing 的告警做聚合:计数 + 1,更新 last_at 与明细。
|
||||||
|
for existing in self._alerts.values():
|
||||||
|
if existing.aggregate_key == alert.aggregate_key and existing.status == "firing":
|
||||||
|
existing.count += 1
|
||||||
|
existing.last_at = alert.last_at
|
||||||
|
existing.detail = self._merge_detail(existing.detail, alert.detail)
|
||||||
|
return existing
|
||||||
|
self._alerts[alert.alert_id] = alert
|
||||||
|
return alert
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_detail(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
merged = dict(a)
|
||||||
|
hosts = list(dict.fromkeys(list(merged.get("hosts", [])) + list(b.get("hosts", []))))
|
||||||
|
merged["hosts"] = hosts
|
||||||
|
for key in ("current", "threshold", "metric"):
|
||||||
|
if key in b:
|
||||||
|
merged[key] = b[key]
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def get(self, alert_id: str) -> Optional[Alert]:
|
||||||
|
with self._lock:
|
||||||
|
return self._alerts.get(alert_id)
|
||||||
|
|
||||||
|
def list_alerts(
|
||||||
|
self,
|
||||||
|
severity: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
ack_status: Optional[str] = None,
|
||||||
|
start: Optional[float] = None,
|
||||||
|
end: Optional[float] = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> Tuple[int, List[Alert]]:
|
||||||
|
with self._lock:
|
||||||
|
items = [
|
||||||
|
a
|
||||||
|
for a in self._alerts.values()
|
||||||
|
if self._match_alert(a, severity, status, ack_status, start, end)
|
||||||
|
]
|
||||||
|
items.sort(key=lambda a: a.last_at, reverse=True)
|
||||||
|
total = len(items)
|
||||||
|
start_idx = (page - 1) * page_size
|
||||||
|
return total, items[start_idx : start_idx + page_size]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _match_alert(
|
||||||
|
a: Alert,
|
||||||
|
severity: Optional[str],
|
||||||
|
status: Optional[str],
|
||||||
|
ack_status: Optional[str],
|
||||||
|
start: Optional[float],
|
||||||
|
end: Optional[float],
|
||||||
|
) -> bool:
|
||||||
|
if severity and a.severity != severity:
|
||||||
|
return False
|
||||||
|
if status and a.status != status:
|
||||||
|
return False
|
||||||
|
if ack_status and a.ack_status != ack_status:
|
||||||
|
return False
|
||||||
|
if start is not None and a.last_at < start:
|
||||||
|
return False
|
||||||
|
if end is not None and a.last_at > end:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def ack(self, alert_id: str, ack_by: str = "system") -> Optional[Alert]:
|
||||||
|
with self._lock:
|
||||||
|
a = self._alerts.get(alert_id)
|
||||||
|
if a is None:
|
||||||
|
return None
|
||||||
|
a.ack_status = "acked"
|
||||||
|
a.ack_by = ack_by
|
||||||
|
a.ack_at = time.time()
|
||||||
|
return a
|
||||||
|
|
||||||
|
def close(self, alert_id: str) -> Optional[Alert]:
|
||||||
|
with self._lock:
|
||||||
|
a = self._alerts.get(alert_id)
|
||||||
|
if a is None:
|
||||||
|
return None
|
||||||
|
a.ack_status = "closed"
|
||||||
|
return a
|
||||||
|
|
||||||
|
def resolve_by_dedup_key(self, dedup_key: str, at: float) -> Optional[Alert]:
|
||||||
|
with self._lock:
|
||||||
|
for a in self._alerts.values():
|
||||||
|
if a.dedup_key == dedup_key and a.status == "firing":
|
||||||
|
a.status = "resolved"
|
||||||
|
a.last_at = at
|
||||||
|
return a
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryCache(Cache):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._data: Dict[str, Tuple[str, Optional[float]]] = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def _now(self) -> float:
|
||||||
|
return time.monotonic()
|
||||||
|
|
||||||
|
def _purge(self, key: str) -> None:
|
||||||
|
if key in self._data:
|
||||||
|
value, exp = self._data[key]
|
||||||
|
if exp is not None and exp <= self._now():
|
||||||
|
del self._data[key]
|
||||||
|
|
||||||
|
def get(self, key: str) -> Optional[str]:
|
||||||
|
with self._lock:
|
||||||
|
self._purge(key)
|
||||||
|
item = self._data.get(key)
|
||||||
|
return item[0] if item else None
|
||||||
|
|
||||||
|
def set(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
exp = self._now() + ttl if ttl else None
|
||||||
|
self._data[key] = (value, exp)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_nx(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
self._purge(key)
|
||||||
|
if key in self._data:
|
||||||
|
return False
|
||||||
|
exp = self._now() + ttl if ttl else None
|
||||||
|
self._data[key] = (value, exp)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def incr(self, key: str, ttl: Optional[int] = None) -> int:
|
||||||
|
with self._lock:
|
||||||
|
self._purge(key)
|
||||||
|
item = self._data.get(key)
|
||||||
|
current = int(item[0]) + 1 if item else 1
|
||||||
|
exp = self._now() + ttl if ttl else None
|
||||||
|
self._data[key] = (str(current), exp)
|
||||||
|
return current
|
||||||
|
|
||||||
|
def delete(self, key: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
if key in self._data:
|
||||||
|
del self._data[key]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryMessageBus(MessageBus):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.messages: List[Tuple[str, str, Dict[str, Any]]] = []
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def publish(self, topic: str, key: str, value: Dict[str, Any]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.messages.append((topic, key, value))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 工厂函数:根据配置选择驱动(未安装可选依赖时安全回退 memory)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _safe(loader, fallback):
|
||||||
|
try:
|
||||||
|
return loader()
|
||||||
|
except Exception:
|
||||||
|
return fallback()
|
||||||
|
|
||||||
|
|
||||||
|
def create_rule_store(config) -> RuleStore:
|
||||||
|
if config.rule_store_driver == "mysql":
|
||||||
|
return _safe(lambda: MySQLRuleStore(config.mysql_dsn), InMemoryRuleStore)
|
||||||
|
return InMemoryRuleStore()
|
||||||
|
|
||||||
|
|
||||||
|
def create_event_store(config) -> EventStore:
|
||||||
|
if config.event_store_driver == "mysql":
|
||||||
|
return _safe(lambda: MySQLEventStore(config.mysql_dsn), InMemoryEventStore)
|
||||||
|
return InMemoryEventStore()
|
||||||
|
|
||||||
|
|
||||||
|
def create_alert_store(config) -> AlertStore:
|
||||||
|
if config.alert_store_driver == "mysql":
|
||||||
|
return _safe(lambda: MySQLAlertStore(config.mysql_dsn), InMemoryAlertStore)
|
||||||
|
return InMemoryAlertStore()
|
||||||
|
|
||||||
|
|
||||||
|
def create_cache(config) -> Cache:
|
||||||
|
if config.cache_driver == "redis":
|
||||||
|
return _safe(lambda: RedisCache(config.redis_url), InMemoryCache)
|
||||||
|
return InMemoryCache()
|
||||||
|
|
||||||
|
|
||||||
|
def create_bus(config) -> MessageBus:
|
||||||
|
if config.bus_driver == "kafka":
|
||||||
|
return _safe(lambda: KafkaBus(config.kafka_bootstrap), InMemoryMessageBus)
|
||||||
|
return InMemoryMessageBus()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 可选中间件适配器(惰性导入;未安装驱动时抛出 RuntimeError)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class MySQLRuleStore(RuleStore):
|
||||||
|
"""基于 pymysql 的规则仓库。需安装:pip install pymysql"""
|
||||||
|
|
||||||
|
def __init__(self, dsn: str) -> None:
|
||||||
|
try:
|
||||||
|
import pymysql # noqa: F401
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("MySQLRuleStore requires pymysql") from exc
|
||||||
|
self._dsn = dsn
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
|
def _cursor(self):
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
if self._conn is None:
|
||||||
|
self._conn = pymysql.connect(**self._parse_dsn(self._dsn))
|
||||||
|
return self._conn.cursor()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_dsn(dsn: str) -> Dict[str, Any]:
|
||||||
|
# 简化:mysql://user:pass@host:port/db
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
parsed = urllib.parse.urlparse(dsn)
|
||||||
|
return {
|
||||||
|
"host": parsed.hostname or "127.0.0.1",
|
||||||
|
"port": parsed.port or 3306,
|
||||||
|
"user": parsed.username or "root",
|
||||||
|
"password": parsed.password or "",
|
||||||
|
"database": parsed.path.lstrip("/") or "hms",
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_rules(self) -> List[MetricRule]:
|
||||||
|
cur = self._cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT rule_id,name,metric,aggregation,operator,threshold,threshold2,"
|
||||||
|
"for_duration,severity,labels,notify_channels,enabled,version "
|
||||||
|
"FROM metric_rule"
|
||||||
|
)
|
||||||
|
rules = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
scopes = self._load_scopes(row[0])
|
||||||
|
rules.append(
|
||||||
|
MetricRule(
|
||||||
|
rule_id=row[0], name=row[1], metric=row[2], aggregation=row[3],
|
||||||
|
operator=row[4], threshold=float(row[5]),
|
||||||
|
threshold2=float(row[6]) if row[6] is not None else None,
|
||||||
|
for_duration=row[7], severity=row[8],
|
||||||
|
labels=json.loads(row[9]) if row[9] else {},
|
||||||
|
notify_channels=json.loads(row[10]) if row[10] else [],
|
||||||
|
enabled=bool(row[11]), version=int(row[12]),
|
||||||
|
scope=scopes[0] if scopes else RuleScope(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rules
|
||||||
|
|
||||||
|
def _load_scopes(self, rule_id: str) -> List[RuleScope]:
|
||||||
|
cur = self._cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT scope_type,host_id,host_group,service FROM rule_scope WHERE rule_id=%s",
|
||||||
|
(rule_id,),
|
||||||
|
)
|
||||||
|
scopes = []
|
||||||
|
for st, host_id, host_group, service in cur.fetchall():
|
||||||
|
scopes.append(
|
||||||
|
RuleScope(
|
||||||
|
scope_type=st,
|
||||||
|
host_ids=[host_id] if host_id else [],
|
||||||
|
host_group=host_group or "",
|
||||||
|
service=service or "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return scopes
|
||||||
|
|
||||||
|
def get_rule(self, rule_id: str) -> Optional[MetricRule]:
|
||||||
|
for r in self.list_rules():
|
||||||
|
if r.rule_id == rule_id:
|
||||||
|
return r
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_rule(self, rule: MetricRule) -> MetricRule:
|
||||||
|
raise NotImplementedError("MySQL write path 由上层 API 通过事务实现")
|
||||||
|
|
||||||
|
def update_rule(self, rule: MetricRule) -> MetricRule:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def delete_rule(self, rule_id: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_version(self) -> int:
|
||||||
|
cur = self._cursor()
|
||||||
|
cur.execute("SELECT COALESCE(MAX(version),0) FROM metric_rule")
|
||||||
|
return int(cur.fetchone()[0])
|
||||||
|
|
||||||
|
|
||||||
|
class MySQLEventStore(EventStore):
|
||||||
|
def __init__(self, dsn: str) -> None:
|
||||||
|
try:
|
||||||
|
import pymysql # noqa: F401
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("MySQLEventStore requires pymysql") from exc
|
||||||
|
self._dsn = dsn
|
||||||
|
|
||||||
|
def insert(self, event: Event) -> None:
|
||||||
|
raise NotImplementedError("接入真实 MySQL 时实现 event 表写入")
|
||||||
|
|
||||||
|
def list_events(self, **kwargs) -> Tuple[int, List[Event]]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class MySQLAlertStore(AlertStore):
|
||||||
|
def __init__(self, dsn: str) -> None:
|
||||||
|
try:
|
||||||
|
import pymysql # noqa: F401
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("MySQLAlertStore requires pymysql") from exc
|
||||||
|
self._dsn = dsn
|
||||||
|
|
||||||
|
def upsert_firing(self, alert: Alert) -> Alert:
|
||||||
|
raise NotImplementedError("接入真实 MySQL 时实现 alert 表 upsert")
|
||||||
|
|
||||||
|
def get(self, alert_id: str) -> Optional[Alert]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def list_alerts(self, **kwargs) -> Tuple[int, List[Alert]]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def ack(self, alert_id: str, ack_by: str = "system") -> Optional[Alert]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def close(self, alert_id: str) -> Optional[Alert]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def resolve_by_dedup_key(self, dedup_key: str, at: float) -> Optional[Alert]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class RedisCache(Cache):
|
||||||
|
"""基于 redis-py 的缓存。需安装:pip install redis"""
|
||||||
|
|
||||||
|
def __init__(self, url: str) -> None:
|
||||||
|
try:
|
||||||
|
import redis # noqa: F401
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("RedisCache requires redis") from exc
|
||||||
|
import redis
|
||||||
|
|
||||||
|
self._client = redis.Redis.from_url(url, decode_responses=True)
|
||||||
|
|
||||||
|
def get(self, key: str) -> Optional[str]:
|
||||||
|
return self._client.get(key)
|
||||||
|
|
||||||
|
def set(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
|
||||||
|
return bool(self._client.set(key, value, ex=ttl))
|
||||||
|
|
||||||
|
def set_nx(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
|
||||||
|
return bool(self._client.set(key, value, ex=ttl, nx=True))
|
||||||
|
|
||||||
|
def incr(self, key: str, ttl: Optional[int] = None) -> int:
|
||||||
|
value = self._client.incr(key)
|
||||||
|
if ttl and value == 1:
|
||||||
|
self._client.expire(key, ttl)
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> bool:
|
||||||
|
return bool(self._client.delete(key))
|
||||||
|
|
||||||
|
|
||||||
|
class KafkaBus(MessageBus):
|
||||||
|
"""基于 kafka-python 的消息总线。需安装:pip install kafka-python"""
|
||||||
|
|
||||||
|
def __init__(self, bootstrap: str) -> None:
|
||||||
|
try:
|
||||||
|
from kafka import KafkaProducer # noqa: F401
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise RuntimeError("KafkaBus requires kafka-python") from exc
|
||||||
|
from kafka import KafkaProducer
|
||||||
|
|
||||||
|
self._producer = KafkaProducer(
|
||||||
|
bootstrap_servers=bootstrap,
|
||||||
|
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
|
||||||
|
key_serializer=lambda k: k.encode("utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def publish(self, topic: str, key: str, value: Dict[str, Any]) -> None:
|
||||||
|
self._producer.send(topic, key=key, value=value)
|
||||||
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "threshold-event-detector"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "HMS metric threshold event detector"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = { text = "Proprietary" }
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
mysql = ["pymysql>=1.1"]
|
||||||
|
redis = ["redis>=4.0"]
|
||||||
|
kafka = ["kafka-python>=2.0"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["detector*"]
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# 核心模块仅依赖 Python 标准库,以下为接入真实中间件时的可选依赖:
|
||||||
|
# pymysql>=1.1 # HMS_RULE_STORE_DRIVER=mysql / EVENT/ALERT 同理
|
||||||
|
# redis>=4.0 # HMS_CACHE_DRIVER=redis
|
||||||
|
# kafka-python>=2.0 # HMS_BUS_DRIVER=kafka
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
BIN
tests/__pycache__/test_api.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_api.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_converger.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_converger.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_engine.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_engine.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_models.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_models.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_notifier.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_notifier.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_rule_loader.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_rule_loader.cpython-310.pyc
Normal file
Binary file not shown.
120
tests/test_api.py
Normal file
120
tests/test_api.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
import urllib.request
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
|
||||||
|
from detector.api import APIServer
|
||||||
|
from detector.app import DetectorApp
|
||||||
|
from detector.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class TestAPI(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
config = Config.from_env()
|
||||||
|
config.http_addr = "127.0.0.1:0"
|
||||||
|
self.app = DetectorApp(config)
|
||||||
|
self.app.seed_demo_rules()
|
||||||
|
self.app.start()
|
||||||
|
self.api = APIServer(self.app, "127.0.0.1:0")
|
||||||
|
self.port = self.api.server.server_address[1]
|
||||||
|
self.base = f"http://127.0.0.1:{self.port}"
|
||||||
|
self.thread = threading.Thread(target=self.api.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.api.shutdown()
|
||||||
|
self.api.server.server_close()
|
||||||
|
self.app.stop()
|
||||||
|
|
||||||
|
def _request(self, method, path, body=None):
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self.base + path,
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||||
|
except HTTPError as exc:
|
||||||
|
return exc.code, json.loads(exc.read().decode("utf-8"))
|
||||||
|
|
||||||
|
def test_healthz(self):
|
||||||
|
status, body = self._request("GET", "/healthz")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(body["data"]["status"], "ok")
|
||||||
|
|
||||||
|
def test_list_rules(self):
|
||||||
|
status, body = self._request("GET", "/api/v1/rules")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertGreaterEqual(body["data"]["total"], 3)
|
||||||
|
|
||||||
|
def test_create_and_get_rule(self):
|
||||||
|
payload = {
|
||||||
|
"rule_id": "r-test",
|
||||||
|
"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.assertEqual(body["data"]["rule_id"], "r-test")
|
||||||
|
|
||||||
|
status, body = self._request("GET", "/api/v1/rules/r-test")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(body["data"]["threshold"], 5.0)
|
||||||
|
|
||||||
|
def test_ingest_triggers_firing_event(self):
|
||||||
|
samples = [
|
||||||
|
{"name": "cpu_usage", "value": 95.0, "timestamp": float(i * 15), "labels": {"service": "web"}}
|
||||||
|
for i in range(5)
|
||||||
|
]
|
||||||
|
status, body = self._request(
|
||||||
|
"POST", "/api/v1/ingest",
|
||||||
|
{"host_id": "h-001", "host_group": "web", "samples": samples},
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
events = body["data"]["events"]
|
||||||
|
self.assertEqual(len(events), 1)
|
||||||
|
self.assertEqual(events[0]["status"], "firing")
|
||||||
|
|
||||||
|
# 事件已落库
|
||||||
|
status, body = self._request("GET", "/api/v1/events?host_id=h-001")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(body["data"]["total"], 1)
|
||||||
|
|
||||||
|
# 告警已生成
|
||||||
|
status, body = self._request("GET", "/api/v1/alerts")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(body["data"]["total"], 1)
|
||||||
|
alert_id = body["data"]["items"][0]["alert_id"]
|
||||||
|
|
||||||
|
# 确认告警
|
||||||
|
status, body = self._request("POST", f"/api/v1/alerts/{alert_id}/ack", {"ack_by": "tester"})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(body["data"]["ack_status"], "acked")
|
||||||
|
|
||||||
|
def test_batch_ack(self):
|
||||||
|
self._request(
|
||||||
|
"POST", "/api/v1/ingest",
|
||||||
|
{"host_id": "h-002", "host_group": "web",
|
||||||
|
"samples": [{"name": "cpu_usage", "value": 95.0, "timestamp": float(i * 15)} for i in range(5)]},
|
||||||
|
)
|
||||||
|
status, body = self._request("GET", "/api/v1/alerts")
|
||||||
|
alert_ids = [a["alert_id"] for a in body["data"]["items"]]
|
||||||
|
status, body = self._request("POST", "/api/v1/alerts/batch-ack", {"alert_ids": alert_ids})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(body["data"]["acked"], len(alert_ids))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
71
tests/test_converger.py
Normal file
71
tests/test_converger.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from detector.converger import AlertConverger
|
||||||
|
from detector.models import Event
|
||||||
|
from detector.storage import InMemoryAlertStore, InMemoryCache, InMemoryMessageBus
|
||||||
|
|
||||||
|
|
||||||
|
def make_event(status="firing", host_id="h-1", rule_id="r-1", fired_at=100.0):
|
||||||
|
return Event(
|
||||||
|
event_id=f"e-{status}-{host_id}",
|
||||||
|
host_id=host_id,
|
||||||
|
rule_id=rule_id,
|
||||||
|
metric="cpu_usage",
|
||||||
|
agg_value=95.0,
|
||||||
|
threshold=90.0,
|
||||||
|
operator="gt",
|
||||||
|
status=status,
|
||||||
|
severity="critical",
|
||||||
|
fired_at=fired_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlertConverger(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.store = InMemoryAlertStore()
|
||||||
|
self.cache = InMemoryCache()
|
||||||
|
self.bus = InMemoryMessageBus()
|
||||||
|
self.converger = AlertConverger(
|
||||||
|
self.store, self.cache, self.bus, dedup_window="5m", aggregate_window="1m",
|
||||||
|
host_group_resolver=lambda h: "web",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dedup_suppresses_duplicate(self):
|
||||||
|
first = self.converger.handle_event(make_event())
|
||||||
|
self.assertIsNotNone(first)
|
||||||
|
second = self.converger.handle_event(make_event())
|
||||||
|
self.assertIsNone(second)
|
||||||
|
self.assertEqual(len(self.store.list_alerts()[1]), 1)
|
||||||
|
|
||||||
|
def test_resolve_clears_dedup(self):
|
||||||
|
self.converger.handle_event(make_event(fired_at=100.0))
|
||||||
|
resolved = self.converger.handle_event(make_event(status="resolved", fired_at=200.0))
|
||||||
|
self.assertIsNotNone(resolved)
|
||||||
|
self.assertEqual(resolved.status, "resolved")
|
||||||
|
|
||||||
|
# 去重键已清除,恢复后再次 firing 可以产生新告警
|
||||||
|
again = self.converger.handle_event(make_event(fired_at=300.0))
|
||||||
|
self.assertIsNotNone(again)
|
||||||
|
|
||||||
|
def test_aggregate_same_bucket(self):
|
||||||
|
a1 = self.converger.handle_event(make_event(host_id="h-1", fired_at=100.0))
|
||||||
|
a2 = self.converger.handle_event(make_event(host_id="h-2", fired_at=110.0))
|
||||||
|
self.assertIsNotNone(a1)
|
||||||
|
self.assertIsNotNone(a2)
|
||||||
|
alerts = self.store.list_alerts()[1]
|
||||||
|
# 同一 aggregate_key(同 bucket/severity/rule/group)聚合为一条
|
||||||
|
self.assertEqual(len(alerts), 1)
|
||||||
|
self.assertEqual(alerts[0].count, 2)
|
||||||
|
self.assertIn("h-1", alerts[0].detail["hosts"])
|
||||||
|
self.assertIn("h-2", alerts[0].detail["hosts"])
|
||||||
|
|
||||||
|
def test_publish_to_bus(self):
|
||||||
|
self.converger.handle_event(make_event())
|
||||||
|
self.assertEqual(len(self.bus.messages), 1)
|
||||||
|
topic, key, value = self.bus.messages[0]
|
||||||
|
self.assertEqual(topic, "alerts.converged")
|
||||||
|
self.assertIn("alert_id", value)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
107
tests/test_engine.py
Normal file
107
tests/test_engine.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from detector.engine import DetectionEngine, aggregate_value, compare
|
||||||
|
from detector.models import MetricRule, MetricSample, RuleScope
|
||||||
|
from detector.rule_loader import RuleLoader
|
||||||
|
from detector.storage import InMemoryCache, InMemoryRuleStore
|
||||||
|
|
||||||
|
|
||||||
|
def make_rule(rule_id="r-cpu", metric="cpu_usage", operator="gt", threshold=90.0, for_duration="60s"):
|
||||||
|
return MetricRule(
|
||||||
|
rule_id=rule_id,
|
||||||
|
name="cpu high",
|
||||||
|
metric=metric,
|
||||||
|
aggregation="avg",
|
||||||
|
operator=operator,
|
||||||
|
threshold=threshold,
|
||||||
|
for_duration=for_duration,
|
||||||
|
severity="critical",
|
||||||
|
scope=RuleScope(scope_type="all"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_engine(rule):
|
||||||
|
store = InMemoryRuleStore()
|
||||||
|
store.create_rule(rule)
|
||||||
|
loader = RuleLoader(store, InMemoryCache())
|
||||||
|
loader.load()
|
||||||
|
return DetectionEngine(loader, evaluation_interval="15s", recover_duration="15s")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAggregateCompare(unittest.TestCase):
|
||||||
|
def test_aggregate(self):
|
||||||
|
self.assertEqual(aggregate_value([1, 2, 3], "avg"), 2)
|
||||||
|
self.assertEqual(aggregate_value([1, 2, 3], "min"), 1)
|
||||||
|
self.assertEqual(aggregate_value([1, 2, 3], "max"), 3)
|
||||||
|
self.assertEqual(aggregate_value([1, 2, 3], "sum"), 6)
|
||||||
|
self.assertEqual(aggregate_value([1, 2, 3], "last"), 3)
|
||||||
|
|
||||||
|
def test_compare(self):
|
||||||
|
self.assertTrue(compare(91, "gt", 90))
|
||||||
|
self.assertFalse(compare(90, "gt", 90))
|
||||||
|
self.assertTrue(compare(90, "gte", 90))
|
||||||
|
self.assertTrue(compare(89, "lt", 90))
|
||||||
|
self.assertTrue(compare(95, "between", 90, 100))
|
||||||
|
self.assertFalse(compare(105, "between", 90, 100))
|
||||||
|
|
||||||
|
|
||||||
|
class TestStateMachine(unittest.TestCase):
|
||||||
|
def test_firing_after_sustained(self):
|
||||||
|
rule = make_rule(for_duration="60s")
|
||||||
|
engine = make_engine(rule)
|
||||||
|
events = []
|
||||||
|
for i in range(5):
|
||||||
|
ts = float(i * 15)
|
||||||
|
sample = MetricSample(name="cpu_usage", value=95.0, timestamp=ts)
|
||||||
|
events.extend(engine.handle(sample, "h-1"))
|
||||||
|
|
||||||
|
self.assertEqual(len(events), 1)
|
||||||
|
self.assertEqual(events[0].status, "firing")
|
||||||
|
self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "firing")
|
||||||
|
|
||||||
|
def test_no_fire_before_for_duration(self):
|
||||||
|
rule = make_rule(for_duration="60s")
|
||||||
|
engine = make_engine(rule)
|
||||||
|
events = []
|
||||||
|
for i in range(3):
|
||||||
|
ts = float(i * 15)
|
||||||
|
events.extend(engine.handle(MetricSample(name="cpu_usage", value=95.0, timestamp=ts), "h-1"))
|
||||||
|
self.assertEqual(events, [])
|
||||||
|
self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "ok")
|
||||||
|
|
||||||
|
def test_resolved(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(engine.state_of("h-1", rule.rule_id)["status"], "firing")
|
||||||
|
|
||||||
|
# 一次回落进入 pending
|
||||||
|
events.clear()
|
||||||
|
events.extend(engine.handle(MetricSample(name="cpu_usage", value=50.0, timestamp=75.0), "h-1"))
|
||||||
|
self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "pending")
|
||||||
|
|
||||||
|
# 观察期后仍回落 -> resolved
|
||||||
|
events.clear()
|
||||||
|
events.extend(engine.handle(MetricSample(name="cpu_usage", value=50.0, timestamp=90.0), "h-1"))
|
||||||
|
self.assertEqual(len(events), 1)
|
||||||
|
self.assertEqual(events[0].status, "resolved")
|
||||||
|
self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "ok")
|
||||||
|
|
||||||
|
def test_pending_back_to_firing_no_duplicate_event(self):
|
||||||
|
rule = make_rule(for_duration="60s")
|
||||||
|
engine = make_engine(rule)
|
||||||
|
for i in range(5):
|
||||||
|
engine.handle(MetricSample(name="cpu_usage", value=95.0, timestamp=float(i * 15)), "h-1")
|
||||||
|
engine.handle(MetricSample(name="cpu_usage", value=50.0, timestamp=75.0), "h-1")
|
||||||
|
self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "pending")
|
||||||
|
|
||||||
|
# 观察期内再次满足(低值样本滑出窗口后聚合值回升)-> 回到 firing,不重复触发
|
||||||
|
events = engine.handle(MetricSample(name="cpu_usage", value=96.0, timestamp=150.0), "h-1")
|
||||||
|
self.assertEqual(events, [])
|
||||||
|
self.assertEqual(engine.state_of("h-1", rule.rule_id)["status"], "firing")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
61
tests/test_models.py
Normal file
61
tests/test_models.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from detector.models import MetricRule, RuleScope, parse_duration
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseDuration(unittest.TestCase):
|
||||||
|
def test_units(self):
|
||||||
|
self.assertEqual(parse_duration("60s"), 60)
|
||||||
|
self.assertEqual(parse_duration("5m"), 300)
|
||||||
|
self.assertEqual(parse_duration("1h"), 3600)
|
||||||
|
self.assertEqual(parse_duration("1h30m"), 5400)
|
||||||
|
self.assertEqual(parse_duration("90"), 90)
|
||||||
|
self.assertEqual(parse_duration(""), 0)
|
||||||
|
self.assertEqual(parse_duration(None), 0)
|
||||||
|
|
||||||
|
def test_invalid(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
parse_duration("abc")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuleScope(unittest.TestCase):
|
||||||
|
def test_all(self):
|
||||||
|
scope = RuleScope(scope_type="all")
|
||||||
|
self.assertTrue(scope.matches("h-1", "web", "svc"))
|
||||||
|
self.assertTrue(scope.matches("h-2", None, None))
|
||||||
|
|
||||||
|
def test_host_ids(self):
|
||||||
|
scope = RuleScope(scope_type="host_ids", host_ids=["h-1", "h-2"])
|
||||||
|
self.assertTrue(scope.matches("h-1", None, None))
|
||||||
|
self.assertFalse(scope.matches("h-3", None, None))
|
||||||
|
|
||||||
|
def test_host_group(self):
|
||||||
|
scope = RuleScope(scope_type="host_group", host_group="web")
|
||||||
|
self.assertTrue(scope.matches("h-1", "web", None))
|
||||||
|
self.assertFalse(scope.matches("h-1", "db", None))
|
||||||
|
self.assertFalse(scope.matches("h-1", None, None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetricRuleSerialization(unittest.TestCase):
|
||||||
|
def test_round_trip(self):
|
||||||
|
rule = MetricRule(
|
||||||
|
rule_id="r-1",
|
||||||
|
name="cpu",
|
||||||
|
metric="cpu_usage",
|
||||||
|
operator="gt",
|
||||||
|
threshold=90,
|
||||||
|
scope=RuleScope(scope_type="host_group", host_group="web"),
|
||||||
|
labels={"service": "web"},
|
||||||
|
notify_channels=["email"],
|
||||||
|
)
|
||||||
|
data = rule.to_dict()
|
||||||
|
restored = MetricRule.from_dict(data)
|
||||||
|
self.assertEqual(restored.rule_id, "r-1")
|
||||||
|
self.assertEqual(restored.threshold, 90)
|
||||||
|
self.assertEqual(restored.scope.host_group, "web")
|
||||||
|
self.assertEqual(restored.labels, {"service": "web"})
|
||||||
|
self.assertEqual(restored.notify_channels, ["email"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
23
tests/test_notifier.py
Normal file
23
tests/test_notifier.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from detector.models import Alert
|
||||||
|
from detector.notifier import InMemorySender, Notifier
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotifier(unittest.TestCase):
|
||||||
|
def test_memory_notifier(self):
|
||||||
|
notifier = Notifier.memory(["email", "webhook"])
|
||||||
|
alert = Alert(alert_id="a-1", dedup_key="h-1:r-1", aggregate_key="k", severity="critical", status="firing")
|
||||||
|
delivered = notifier.notify(alert, ["email", "webhook", "dingtalk"])
|
||||||
|
self.assertEqual(delivered, ["email", "webhook"])
|
||||||
|
self.assertEqual(len(notifier.sent), 2)
|
||||||
|
|
||||||
|
def test_in_memory_sender(self):
|
||||||
|
sender = InMemorySender("email")
|
||||||
|
alert = Alert(alert_id="a-2", dedup_key="d", aggregate_key="k", severity="warning", status="firing")
|
||||||
|
self.assertTrue(sender.send(alert, "ops@example.com"))
|
||||||
|
self.assertEqual(sender.sent[0]["target"], "ops@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
54
tests/test_rule_loader.py
Normal file
54
tests/test_rule_loader.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from detector.models import MetricRule, MetricSample, RuleScope
|
||||||
|
from detector.rule_loader import RuleLoader
|
||||||
|
from detector.storage import InMemoryCache, InMemoryRuleStore
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuleLoader(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.store = InMemoryRuleStore()
|
||||||
|
self.cache = InMemoryCache()
|
||||||
|
self.loader = RuleLoader(self.store, self.cache, reload_interval="10s")
|
||||||
|
|
||||||
|
def test_load_and_match(self):
|
||||||
|
rule = MetricRule(
|
||||||
|
rule_id="r-1",
|
||||||
|
name="cpu",
|
||||||
|
metric="cpu_usage",
|
||||||
|
operator="gt",
|
||||||
|
threshold=90,
|
||||||
|
scope=RuleScope(scope_type="host_group", host_group="web"),
|
||||||
|
)
|
||||||
|
self.store.create_rule(rule)
|
||||||
|
self.loader.load()
|
||||||
|
|
||||||
|
sample = MetricSample(name="cpu_usage", value=95, timestamp=0)
|
||||||
|
matched = self.loader.match(sample, "h-1", host_group="web")
|
||||||
|
self.assertEqual([r.rule_id for r in matched], ["r-1"])
|
||||||
|
|
||||||
|
# 分组不匹配
|
||||||
|
self.assertEqual(self.loader.match(sample, "h-1", host_group="db"), [])
|
||||||
|
|
||||||
|
def test_hot_reload_by_version(self):
|
||||||
|
self.loader.load()
|
||||||
|
self.assertEqual(self.loader.get_rules(), [])
|
||||||
|
|
||||||
|
self.store.create_rule(
|
||||||
|
MetricRule(rule_id="r-2", name="mem", metric="mem_used_percent", threshold=85)
|
||||||
|
)
|
||||||
|
# 版本变化后手动 load 模拟热加载线程
|
||||||
|
self.assertNotEqual(self.store.get_version(), self.loader._version)
|
||||||
|
self.loader.load()
|
||||||
|
self.assertEqual(len(self.loader.get_rules()), 1)
|
||||||
|
|
||||||
|
def test_disabled_rule_not_matched(self):
|
||||||
|
rule = MetricRule(rule_id="r-3", name="disk", metric="disk_used_percent", enabled=False)
|
||||||
|
self.store.create_rule(rule)
|
||||||
|
self.loader.load()
|
||||||
|
sample = MetricSample(name="disk_used_percent", value=95, timestamp=0)
|
||||||
|
self.assertEqual(self.loader.match(sample, "h-1"), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
x
Reference in New Issue
Block a user