254 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""检测引擎:滑动窗口聚合 + 状态机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),
notify_channels=list(rule.notify_channels),
)
# ------------------------------------------------------------------ #
# 状态查询(测试/可观测)
# ------------------------------------------------------------------ #
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()