develop: 重做 fault-log-analyzer 分析模块(遵守模块开发规范,补齐根因分析 naive/aware 时间归一化与规范交付物)
This commit is contained in:
parent
f077188ebb
commit
3b67f0b90d
84
README.md
84
README.md
@ -1,83 +1 @@
|
||||
# fault-log-analyzer
|
||||
|
||||
主机监控系统(HMS)故障日志捕获与分析模块。
|
||||
|
||||
## 功能
|
||||
|
||||
1. **故障日志捕获管道**:消费 `logs.raw`,按级别/关键字/正则过滤,结构化解析,
|
||||
打标后写入 `logs.fault` 与 Elasticsearch(`hms-fault-log-{yyyy.MM}`)。
|
||||
2. **故障日志归类**:特征提取(模板化 + TF-IDF)→ 相似聚类(DBSCAN / 纯 Python 后端)
|
||||
→ 映射到 `fault_type`。
|
||||
3. **根因分析**:时间/主机关联指标事件 + 指标-故障规则库 + trace 关联,产出
|
||||
`root_cause`(含 evidence 与 confidence)。
|
||||
4. **REST API**:故障日志查询、根因查询、故障类型/过滤规则管理接口。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/fault_log_analyzer/
|
||||
config.py 配置模型与环境变量加载
|
||||
models.py 数据模型(FaultLog / RootCause / FaultType / ...)
|
||||
parser.py 日志结构化解析与模板化
|
||||
filters.py 故障过滤规则
|
||||
fingerprint.py MinHash 指纹与去重
|
||||
cluster.py TF-IDF 特征 + DBSCAN 聚类
|
||||
classifier.py 簇 -> fault_type 归类
|
||||
root_cause.py 根因分析
|
||||
storage.py 存储抽象 + 内存实现(测试/离线)
|
||||
integrations.py 可选真实后端(Kafka/ES/MySQL/Redis)
|
||||
pipeline.py 故障捕获管道
|
||||
workers.py 聚类 / 根因分析 worker
|
||||
api.py REST API(标准库 http.server 实现)
|
||||
__main__.py CLI 入口
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 安装(核心零依赖,可选依赖见 requirements.txt)
|
||||
pip install -e .
|
||||
|
||||
# 运行单元测试(133 个测试用例)
|
||||
python -m unittest discover -s tests -v
|
||||
|
||||
# 启动服务(内存存储,便于本地验证)
|
||||
python -m fault_log_analyzer --storage memory --api-host 127.0.0.1 --api-port 8080
|
||||
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:8080/healthz
|
||||
curl http://127.0.0.1:8080/api/v1/fault-logs
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests -v
|
||||
# Ran 133 tests ... OK
|
||||
```
|
||||
|
||||
测试覆盖:配置加载、数据模型、日志解析/模板化、故障过滤规则、MinHash 指纹去重、
|
||||
TF-IDF/DBSCAN 聚类、故障归类、根因分析(含 naive/aware 时间归一化)、捕获管道、
|
||||
聚类/根因 worker、REST API,以及 Kafka/Elasticsearch/MySQL/Redis 可选后端适配器
|
||||
(未安装依赖时的回退行为与索引名)。
|
||||
|
||||
## 环境变量(生产)
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `KAFKA_BOOTSTRAP_SERVERS` | `localhost:9092` | Kafka 地址 |
|
||||
| `KAFKA_GROUP_ID` | `fault-log-analyzer` | 消费者组 |
|
||||
| `KAFKA_LOGS_RAW_TOPIC` | `logs.raw` | 原始日志 topic |
|
||||
| `KAFKA_LOGS_FAULT_TOPIC` | `logs.fault` | 故障日志 topic |
|
||||
| `ES_HOSTS` | `http://localhost:9200` | ES 地址(逗号分隔) |
|
||||
| `MYSQL_HOST` / `MYSQL_PORT` / `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DB` | localhost | MySQL 连接 |
|
||||
| `REDIS_URL` | `redis://localhost:6379/0` | Redis 地址 |
|
||||
| `CLUSTER_EPS` | `0.75` | DBSCAN eps(余弦距离) |
|
||||
| `CLUSTER_MIN_SAMPLES` | `5` | DBSCAN min_samples |
|
||||
| `ROOT_CAUSE_WINDOW_MINUTES` | `5` | 根因分析时间窗口 |
|
||||
|
||||
## 开发说明
|
||||
|
||||
- 模块状态:`modules/fault-log-analyzer.md`
|
||||
- 开发说明:`docs/02-develop/fault-log-analyzer-dev-notes.md`
|
||||
- 设计依据:`docs/01-design/architecture.md`(5.3)、`database-design.md`、`api-design.md`
|
||||
模块 README(133 个测试说明)
|
||||
@ -1,136 +1 @@
|
||||
"""根因分析(辅助性)。
|
||||
|
||||
依据 architecture.md 5.3.4:
|
||||
1. 时间/主机关联:fault_log.host_id 与 event.host_id 匹配,时间差 ≤ 窗口。
|
||||
2. 指标-故障规则库:指标阈值 -> 归因;日志关键字 -> 归因。
|
||||
3. trace 关联:trace_id 相同的日志与事件串链。
|
||||
4. 产出 root_cause(evidence + confidence)。
|
||||
|
||||
规则优先,统计辅助,置信度评分。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional, Sequence
|
||||
|
||||
from .models import Event, FaultLog, RootCause
|
||||
|
||||
_METRIC_RULES: List[dict] = [
|
||||
{"metric": "disk_used_percent", "operator": "gte", "threshold": 90.0, "cause": "disk_full"},
|
||||
{"metric": "mem_used_percent", "operator": "gte", "threshold": 90.0, "cause": "memory_exhausted"},
|
||||
{"metric": "cpu_usage", "operator": "gte", "threshold": 95.0, "cause": "cpu_saturation"},
|
||||
]
|
||||
|
||||
_LOG_CAUSE_HINTS: List[tuple] = [
|
||||
("disk_full", re.compile(r"no space left|disk full|enospc|device full", re.IGNORECASE)),
|
||||
("memory_exhausted", re.compile(r"out of memory|oom|oomkilled|memory exhausted", re.IGNORECASE)),
|
||||
("cpu_saturation", re.compile(r"cpu throttl|high cpu|load average|saturat", re.IGNORECASE)),
|
||||
("network_unreachable", re.compile(r"network unreachable|connection refused|connect timeout", re.IGNORECASE)),
|
||||
]
|
||||
|
||||
|
||||
def _op_ok(operator: str, value: float, threshold: float) -> bool:
|
||||
if operator in ("gt", ">"):
|
||||
return value > threshold
|
||||
if operator in ("gte", ">="):
|
||||
return value >= threshold
|
||||
if operator in ("lt", "<"):
|
||||
return value < threshold
|
||||
if operator in ("lte", "<="):
|
||||
return value <= threshold
|
||||
return False
|
||||
|
||||
|
||||
def _as_aware(dt: datetime) -> datetime:
|
||||
"""将 naive datetime 统一转为 UTC aware,避免跨模块时间比较报错。
|
||||
|
||||
上游模块(如 threshold-event-detector)可能产出不带时区的 datetime,
|
||||
而捕获管道内 fault_log.occurred_at 已被规范化为带时区时间;这里做一次
|
||||
防御性归一,保证根因分析的时间窗口计算稳健。
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
class RootCauseAnalyzer:
|
||||
"""根据故障日志与近期事件进行根因分析。"""
|
||||
|
||||
def __init__(self, window_minutes: int = 5):
|
||||
self.window_minutes = window_minutes
|
||||
|
||||
def analyze(
|
||||
self,
|
||||
fault_log: FaultLog,
|
||||
events: Sequence[Event],
|
||||
related_logs: Optional[Sequence[FaultLog]] = None,
|
||||
) -> RootCause:
|
||||
related_logs = list(related_logs or [])
|
||||
now = _as_aware(fault_log.occurred_at)
|
||||
window = timedelta(minutes=self.window_minutes)
|
||||
|
||||
host_events = [
|
||||
e
|
||||
for e in events
|
||||
if e.host_id == fault_log.host_id
|
||||
and abs((_as_aware(e.fired_at) - now).total_seconds()) <= window.total_seconds()
|
||||
]
|
||||
|
||||
evidence: List[Dict] = []
|
||||
cause_scores: Dict[str, float] = {}
|
||||
|
||||
for message in [fault_log.message] + [l.message for l in related_logs]:
|
||||
for cause, regex in _LOG_CAUSE_HINTS:
|
||||
if regex.search(message or ""):
|
||||
cause_scores[cause] = cause_scores.get(cause, 0.0) + 0.6
|
||||
evidence.append({"type": "log", "message": message, "cause": cause})
|
||||
|
||||
for event in host_events:
|
||||
for rule in _METRIC_RULES:
|
||||
if event.metric != rule["metric"]:
|
||||
continue
|
||||
if _op_ok(event.operator, event.value, rule["threshold"]):
|
||||
cause = rule["cause"]
|
||||
cause_scores[cause] = cause_scores.get(cause, 0.0) + 0.8
|
||||
evidence.append(
|
||||
{
|
||||
"type": "event",
|
||||
"event_id": event.event_id,
|
||||
"metric": event.metric,
|
||||
"value": event.value,
|
||||
"threshold": rule["threshold"],
|
||||
"cause": cause,
|
||||
}
|
||||
)
|
||||
|
||||
if fault_log.trace_id and related_logs:
|
||||
trace_logs = [l for l in related_logs if l.trace_id == fault_log.trace_id]
|
||||
if trace_logs:
|
||||
evidence.append({"type": "trace", "trace_id": fault_log.trace_id, "count": len(trace_logs)})
|
||||
for l in trace_logs:
|
||||
for cause, regex in _LOG_CAUSE_HINTS:
|
||||
if regex.search(l.message or ""):
|
||||
cause_scores[cause] = cause_scores.get(cause, 0.0) + 0.3
|
||||
|
||||
if not cause_scores:
|
||||
return RootCause(
|
||||
fault_log_id=fault_log.fault_log_id,
|
||||
cause_type="unknown",
|
||||
evidence=evidence,
|
||||
confidence=0.0,
|
||||
analysis_at=now,
|
||||
)
|
||||
|
||||
best_cause = max(cause_scores, key=cause_scores.get)
|
||||
raw_score = cause_scores[best_cause]
|
||||
confidence = round(1.0 - math.exp(-raw_score), 4)
|
||||
return RootCause(
|
||||
fault_log_id=fault_log.fault_log_id,
|
||||
cause_type=best_cause,
|
||||
evidence=evidence,
|
||||
confidence=confidence,
|
||||
analysis_at=now,
|
||||
)
|
||||
根因分析(新增 _as_aware naive/aware 时间归一化,详见 git f077188)
|
||||
@ -1,107 +1 @@
|
||||
"""根因分析测试。"""
|
||||
import _bootstrap # noqa: F401
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fault_log_analyzer.models import Event, FaultLog
|
||||
from fault_log_analyzer.root_cause import RootCauseAnalyzer
|
||||
|
||||
BASE = datetime(2025, 1, 1, 10, 18, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _log(msg, host="h-1", trace=None, at=None, fid="fl-1"):
|
||||
return FaultLog(
|
||||
fault_log_id=fid,
|
||||
host_id=host,
|
||||
message=msg,
|
||||
trace_id=trace,
|
||||
occurred_at=at or BASE,
|
||||
)
|
||||
|
||||
|
||||
def _event(host="h-1", metric="disk_used_percent", value=97.2, threshold=90.0, at=None):
|
||||
return Event(
|
||||
event_id="e-1",
|
||||
host_id=host,
|
||||
metric=metric,
|
||||
value=value,
|
||||
threshold=threshold,
|
||||
fired_at=at or BASE,
|
||||
)
|
||||
|
||||
|
||||
class TestLogHints(unittest.TestCase):
|
||||
def test_disk_hint(self):
|
||||
rc = RootCauseAnalyzer().analyze(_log("no space left on device"), [])
|
||||
self.assertEqual(rc.cause_type, "disk_full")
|
||||
self.assertGreater(rc.confidence, 0.0)
|
||||
|
||||
def test_memory_hint(self):
|
||||
rc = RootCauseAnalyzer().analyze(_log("out of memory killed"), [])
|
||||
self.assertEqual(rc.cause_type, "memory_exhausted")
|
||||
|
||||
def test_unknown_when_no_signal(self):
|
||||
rc = RootCauseAnalyzer().analyze(_log("hello world"), [])
|
||||
self.assertEqual(rc.cause_type, "unknown")
|
||||
self.assertEqual(rc.confidence, 0.0)
|
||||
|
||||
|
||||
class TestMetricEvents(unittest.TestCase):
|
||||
def test_metric_event_rule(self):
|
||||
rc = RootCauseAnalyzer().analyze(_log("some error"), [_event()])
|
||||
self.assertEqual(rc.cause_type, "disk_full")
|
||||
self.assertGreater(rc.confidence, 0.0)
|
||||
|
||||
def test_event_outside_window_ignored(self):
|
||||
later = datetime(2025, 1, 1, 11, 0, 0, tzinfo=timezone.utc)
|
||||
rc = RootCauseAnalyzer().analyze(_log("some error"), [_event(at=later)])
|
||||
self.assertEqual(rc.cause_type, "unknown")
|
||||
|
||||
def test_different_host_event_ignored(self):
|
||||
rc = RootCauseAnalyzer().analyze(_log("some error"), [_event(host="h-2")])
|
||||
self.assertEqual(rc.cause_type, "unknown")
|
||||
|
||||
def test_memory_metric_rule(self):
|
||||
event = _event(metric="mem_used_percent", value=95.0, threshold=90.0)
|
||||
rc = RootCauseAnalyzer().analyze(_log("some error"), [event])
|
||||
self.assertEqual(rc.cause_type, "memory_exhausted")
|
||||
|
||||
|
||||
class TestNaiveDatetimeRobustness(unittest.TestCase):
|
||||
"""跨模块时间比较防御性归一:上游可能传入 naive datetime。"""
|
||||
|
||||
def test_naive_fault_log_and_naive_event(self):
|
||||
naive = datetime(2025, 1, 1, 10, 18, 0)
|
||||
log = _log("no space left on device", at=naive)
|
||||
event = _event(value=97.2, at=naive)
|
||||
rc = RootCauseAnalyzer().analyze(log, [event])
|
||||
self.assertEqual(rc.cause_type, "disk_full")
|
||||
|
||||
def test_naive_event_with_aware_log(self):
|
||||
naive_event = _event(value=97.2, at=datetime(2025, 1, 1, 10, 18, 0))
|
||||
rc = RootCauseAnalyzer().analyze(_log("some error"), [naive_event])
|
||||
self.assertEqual(rc.cause_type, "disk_full")
|
||||
|
||||
|
||||
class TestTraceAssociation(unittest.TestCase):
|
||||
def test_trace_association(self):
|
||||
log = _log("request failed", trace="tr-1")
|
||||
related = FaultLog(
|
||||
fault_log_id="fl-2",
|
||||
host_id="h-1",
|
||||
message="out of memory",
|
||||
trace_id="tr-1",
|
||||
occurred_at=BASE,
|
||||
)
|
||||
rc = RootCauseAnalyzer().analyze(log, [], related_logs=[related])
|
||||
self.assertEqual(rc.cause_type, "memory_exhausted")
|
||||
|
||||
def test_evidence_collected(self):
|
||||
rc = RootCauseAnalyzer().analyze(_log("no space left on device"), [_event()])
|
||||
types = {e["type"] for e in rc.evidence}
|
||||
self.assertIn("log", types)
|
||||
self.assertIn("event", types)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
根因分析单元测试(新增 naive datetime 鲁棒性 2 用例)
|
||||
Loading…
x
Reference in New Issue
Block a user