develop: 补齐根因分析 RootCauseAnalyzer 实现与单元测试(132 tests OK)

This commit is contained in:
Pipeline Agent 2026-08-15 12:34:15 +08:00
parent 3b67f0b90d
commit feb44ab079
2 changed files with 274 additions and 2 deletions

View File

@ -1 +1,150 @@
根因分析新增 _as_aware naive/aware 时间归一化详见 git f077188
"""根因分析RootCauseAnalyzer
依据 architecture.md 5.3.4 database-design.md root_cause
- 输入故障日志FaultLog+ 同主机近时间窗口的指标事件Event
- 策略规则优先统计辅助置信度评分
1. 日志关键字启发式复用 Classifier.hint给出候选故障类型
2. 指标-故障规则库 disk_used_percent >= 90 -> disk_full
3. 时间窗口 + 主机 + 事件指标关联匹配则提升置信度
- 输出RootCause{fault_log_id, cause_type, evidence[], confidence}
时间比较统一经过 ``_as_aware`` 归一化兼容 naive / aware datetime
避免直接比较时抛出 TypeError tests/test_root_cause.py 鲁棒性用例
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Sequence, Tuple
from .classifier import Classifier
from .models import Event, FaultLog, RootCause
# 指标-故障规则库:(metric, operator, threshold, cause_type)
# operator 支持gt / gte / lt / lte / eq
_METRIC_RULES: List[Tuple[str, str, float, str]] = [
("disk_used_percent", "gte", 90.0, "disk_full"),
("mem_used_percent", "gte", 90.0, "memory_exhausted"),
("cpu_usage", "gte", 95.0, "cpu_saturation"),
("load_1m", "gte", 20.0, "cpu_saturation"),
("net_pkt_drop", "gte", 100.0, "network_unreachable"),
]
# OOM 日志关键字(补充 Classifier.hint 已覆盖之外的显式规则,保持可读性)
_OOM_RE = re.compile(r"oomkilled|out of memory|oom", re.IGNORECASE)
def _as_aware(dt: Optional[datetime]) -> Optional[datetime]:
"""将 naive datetime 归一化为 UTC aware datetime。
aware datetime 原样返回naive datetime 视为 UTC
"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
def _compare(value: float, operator: str, threshold: float) -> bool:
if operator == "gt":
return value > threshold
if operator == "gte":
return value >= threshold
if operator == "lt":
return value < threshold
if operator == "lte":
return value <= threshold
if operator == "eq":
return value == threshold
return False
def _hint_cause(message: str) -> Optional[str]:
"""日志消息 -> 候选故障类型(复用分类器启发式规则)。"""
return Classifier.hint(message or "")
def _match_metric_rule(event: Event) -> Optional[str]:
"""指标事件命中规则库时返回对应 cause_type否则 None。"""
for metric, operator, threshold, cause_type in _METRIC_RULES:
if event.metric == metric and _compare(event.value, operator, threshold):
return cause_type
return None
class RootCauseAnalyzer:
"""规则优先 + 统计辅助 + 置信度评分的根因分析器。"""
def __init__(self, window_minutes: int = 5):
self.window_minutes = window_minutes
def analyze(self, log: FaultLog, events: Sequence[Event]) -> RootCause:
"""分析单条故障日志,返回根因结论。
置信度规则确定性可测试
- 日志关键字命中 +0.6
- 指标规则命中 +0.25
- 二者同时命中且结论一致 +0.15互相印证
"""
hint_cause = _hint_cause(log.message)
event_cause, event_evidence = self._correlate_events(log, events)
cause_type = event_cause or hint_cause or "unknown"
confidence = 0.0
if hint_cause:
confidence += 0.6
if event_cause:
confidence += 0.25
if hint_cause and event_cause and hint_cause == event_cause:
confidence += 0.15
evidence: List[Dict] = [{"type": "log", "message": log.message}]
evidence.extend(event_evidence)
return RootCause(
fault_log_id=log.fault_log_id,
cause_type=cause_type,
evidence=evidence,
confidence=round(min(1.0, confidence), 2),
analysis_at=datetime.now(timezone.utc),
)
def _correlate_events(
self, log: FaultLog, events: Sequence[Event]
) -> Tuple[Optional[str], List[Dict]]:
"""按时间窗口 + 主机过滤事件,并匹配指标规则库。"""
window = timedelta(minutes=self.window_minutes)
log_time = _as_aware(log.occurred_at)
start = log_time - window if log_time else None
end = log_time + window if log_time else None
event_cause: Optional[str] = None
evidence: List[Dict] = []
for event in events:
fired = _as_aware(event.fired_at)
if fired is None:
continue
if start is not None and end is not None:
if not (start <= fired <= end):
continue
if log.host_id and event.host_id and event.host_id != log.host_id:
continue
evidence.append(
{
"type": "event",
"event_id": event.event_id,
"metric": event.metric,
"value": event.value,
}
)
matched = _match_metric_rule(event)
if matched and event_cause is None:
event_cause = matched
return event_cause, evidence

View File

@ -1 +1,124 @@
根因分析单元测试新增 naive datetime 鲁棒性 2 用例
"""根因分析RootCauseAnalyzer单元测试。"""
import _bootstrap # noqa: F401
import unittest
from datetime import datetime, timedelta, timezone
from fault_log_analyzer.models import Event, FaultLog
from fault_log_analyzer.root_cause import RootCauseAnalyzer, _as_aware
BASE = datetime(2025, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
NAIVE_BASE = datetime(2025, 1, 1, 10, 0, 0)
def _log(message, occurred_at=BASE, host_id="h-1", fault_log_id="fl-1"):
return FaultLog(
fault_log_id=fault_log_id,
host_id=host_id,
message=message,
level="ERROR",
occurred_at=occurred_at,
)
class TestAsAware(unittest.TestCase):
def test_aware_kept(self):
self.assertEqual(_as_aware(BASE), BASE)
def test_naive_becomes_utc(self):
out = _as_aware(NAIVE_BASE)
self.assertEqual(out.tzinfo, timezone.utc)
self.assertEqual(out, BASE)
def test_none(self):
self.assertIsNone(_as_aware(None))
class TestRootCauseAnalyzer(unittest.TestCase):
def setUp(self):
self.analyzer = RootCauseAnalyzer(window_minutes=5)
def test_log_hint_only(self):
rc = self.analyzer.analyze(_log("No space left on device /var"), [])
self.assertEqual(rc.cause_type, "disk_full")
self.assertEqual(rc.confidence, 0.6)
self.assertEqual(rc.fault_log_id, "fl-1")
def test_unknown_without_evidence(self):
rc = self.analyzer.analyze(_log("some unrelated message"), [])
self.assertEqual(rc.cause_type, "unknown")
self.assertEqual(rc.confidence, 0.0)
def test_matching_event_corroborates(self):
event = Event(
event_id="e-1",
host_id="h-1",
metric="disk_used_percent",
value=97.2,
threshold=90.0,
fired_at=BASE,
)
rc = self.analyzer.analyze(_log("No space left on device"), [event])
self.assertEqual(rc.cause_type, "disk_full")
# 0.6(日志) + 0.25(指标) + 0.15(互相印证) = 1.0
self.assertEqual(rc.confidence, 1.0)
self.assertTrue(any(e["type"] == "event" and e["event_id"] == "e-1" for e in rc.evidence))
def test_naive_datetime_aware_event(self):
"""naive 日志时间 + aware 事件时间混用不应抛异常(鲁棒性)。"""
event = Event(
event_id="e-1",
host_id="h-1",
metric="mem_used_percent",
value=95.0,
threshold=90.0,
fired_at=BASE,
)
rc = self.analyzer.analyze(_log("out of memory", occurred_at=NAIVE_BASE), [event])
self.assertEqual(rc.cause_type, "memory_exhausted")
self.assertEqual(rc.confidence, 1.0)
def test_naive_event_outside_window(self):
"""窗口外的 naive 事件不应被关联,也不应抛异常(鲁棒性)。"""
outside = Event(
event_id="e-1",
host_id="h-1",
metric="disk_used_percent",
value=97.2,
threshold=90.0,
fired_at=NAIVE_BASE + timedelta(hours=1),
)
rc = self.analyzer.analyze(_log("No space left on device"), [outside])
self.assertEqual(rc.cause_type, "disk_full")
self.assertEqual(rc.confidence, 0.6)
self.assertEqual(len(rc.evidence), 1) # 仅日志证据,无事件证据
def test_host_mismatch_excluded(self):
event = Event(
event_id="e-1",
host_id="h-other",
metric="disk_used_percent",
value=97.2,
threshold=90.0,
fired_at=BASE,
)
rc = self.analyzer.analyze(_log("No space left on device"), [event])
self.assertEqual(rc.cause_type, "disk_full")
self.assertEqual(rc.confidence, 0.6)
def test_event_only_cause(self):
"""日志无关键字但事件命中规则库时,以事件规则为准。"""
event = Event(
event_id="e-1",
host_id="h-1",
metric="cpu_usage",
value=99.0,
threshold=95.0,
fired_at=BASE,
)
rc = self.analyzer.analyze(_log("service degraded"), [event])
self.assertEqual(rc.cause_type, "cpu_saturation")
self.assertEqual(rc.confidence, 0.25)
if __name__ == "__main__":
unittest.main()