diff --git a/README.md b/README.md index 7a22b7e..a38458d 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ src/fault_log_analyzer/ # 安装(核心零依赖,可选依赖见 requirements.txt) pip install -e . -# 运行单元测试(131 个测试用例) +# 运行单元测试(133 个测试用例) python -m unittest discover -s tests -v # 启动服务(内存存储,便于本地验证) @@ -53,12 +53,13 @@ curl http://127.0.0.1:8080/api/v1/fault-logs ```bash python -m unittest discover -s tests -v -# Ran 131 tests ... OK +# Ran 133 tests ... OK ``` 测试覆盖:配置加载、数据模型、日志解析/模板化、故障过滤规则、MinHash 指纹去重、 -TF-IDF/DBSCAN 聚类、故障归类、根因分析、捕获管道、聚类/根因 worker、REST API、 -以及 Kafka/Elasticsearch/MySQL/Redis 可选后端适配器(未安装依赖时的回退行为与索引名)。 +TF-IDF/DBSCAN 聚类、故障归类、根因分析(含 naive/aware 时间归一化)、捕获管道、 +聚类/根因 worker、REST API,以及 Kafka/Elasticsearch/MySQL/Redis 可选后端适配器 +(未安装依赖时的回退行为与索引名)。 ## 环境变量(生产) diff --git a/src/fault_log_analyzer/root_cause.py b/src/fault_log_analyzer/root_cause.py index 77d27af..3ac4636 100644 --- a/src/fault_log_analyzer/root_cause.py +++ b/src/fault_log_analyzer/root_cause.py @@ -13,7 +13,7 @@ from __future__ import annotations import math import re -from datetime import timedelta +from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional, Sequence from .models import Event, FaultLog, RootCause @@ -44,6 +44,18 @@ def _op_ok(operator: str, value: float, threshold: float) -> bool: 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: """根据故障日志与近期事件进行根因分析。""" @@ -57,14 +69,14 @@ class RootCauseAnalyzer: related_logs: Optional[Sequence[FaultLog]] = None, ) -> RootCause: related_logs = list(related_logs or []) - now = fault_log.occurred_at + 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((e.fired_at - now).total_seconds()) <= window.total_seconds() + and abs((_as_aware(e.fired_at) - now).total_seconds()) <= window.total_seconds() ] evidence: List[Dict] = [] diff --git a/tests/test_root_cause.py b/tests/test_root_cause.py index fee6d87..e0669d1 100644 --- a/tests/test_root_cause.py +++ b/tests/test_root_cause.py @@ -67,6 +67,22 @@ class TestMetricEvents(unittest.TestCase): 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")