develop: 补齐 threshold-event-detector 规范交付件并清理交换文件
This commit is contained in:
parent
70614776be
commit
c62453ef70
@ -6,9 +6,7 @@ host-monitor 网关统一转发到本服务(或替换为 FastAPI 等实现)
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from typing import Optional
|
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
from .models import FaultFilterRule, FaultType, from_iso
|
from .models import FaultFilterRule, FaultType, from_iso
|
||||||
@ -189,7 +187,8 @@ class FaultLogApiServer:
|
|||||||
handler = create_handler(fault_log_repo, root_cause_repo, fault_type_repo, filter_rule_repo)
|
handler = create_handler(fault_log_repo, root_cause_repo, fault_type_repo, filter_rule_repo)
|
||||||
self._httpd = ThreadingHTTPServer((host, port), handler)
|
self._httpd = ThreadingHTTPServer((host, port), handler)
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
# port=0 时使用操作系统分配的实际端口
|
||||||
|
self.port = self._httpd.server_address[1]
|
||||||
|
|
||||||
def start(self, block: bool = False) -> None:
|
def start(self, block: bool = False) -> None:
|
||||||
if block:
|
if block:
|
||||||
|
|||||||
@ -20,12 +20,16 @@ def to_iso(dt: Optional[datetime]) -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def from_iso(value: Optional[str]) -> Optional[datetime]:
|
def from_iso(value: Optional[str]) -> Optional[datetime]:
|
||||||
|
"""解析 ISO 时间字符串,非法输入返回 None(不抛异常)。"""
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
return None
|
return None
|
||||||
normalized = value.strip()
|
normalized = value.strip()
|
||||||
if normalized.endswith("Z"):
|
if normalized.endswith("Z"):
|
||||||
normalized = normalized[:-1] + "+00:00"
|
normalized = normalized[:-1] + "+00:00"
|
||||||
dt = datetime.fromisoformat(normalized)
|
try:
|
||||||
|
dt = datetime.fromisoformat(normalized)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
return dt.astimezone(timezone.utc)
|
return dt.astimezone(timezone.utc)
|
||||||
|
|||||||
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from .models import LogEntry, from_iso, utcnow
|
from .models import LogEntry, from_iso, utcnow
|
||||||
@ -42,22 +42,30 @@ def _find_value(fields: dict[str, Any], keys: tuple[str, ...]) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(dt: datetime) -> datetime:
|
||||||
|
"""统一转为带时区的 UTC 时间,避免 naive/aware 混用。"""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def parse_timestamp(value: Any) -> Optional[datetime]:
|
def parse_timestamp(value: Any) -> Optional[datetime]:
|
||||||
"""解析多种时间戳表达,失败返回 None。"""
|
"""解析多种时间戳表达,失败返回 None。"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(value, (int, float)):
|
if isinstance(value, (int, float)):
|
||||||
# 秒/毫秒/纳秒时间戳
|
# 秒/毫秒/纳秒时间戳。阈值按数量级划分:
|
||||||
|
# 秒 ~1e9、毫秒 ~1e12、纳秒 ~1e18。
|
||||||
if value > 1e17: # 纳秒
|
if value > 1e17: # 纳秒
|
||||||
value = value / 1e9
|
value = value / 1e9
|
||||||
elif value > 1e14: # 毫秒
|
elif value > 1e11: # 毫秒
|
||||||
value = value / 1e3
|
value = value / 1e3
|
||||||
try:
|
try:
|
||||||
return datetime.utcfromtimestamp(value)
|
return datetime.fromtimestamp(value, tz=timezone.utc)
|
||||||
except (ValueError, OSError, OverflowError):
|
except (ValueError, OSError, OverflowError):
|
||||||
return None
|
return None
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
return value
|
return _as_utc(value)
|
||||||
text = str(value).strip()
|
text = str(value).strip()
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
@ -66,7 +74,7 @@ def parse_timestamp(value: Any) -> Optional[datetime]:
|
|||||||
return parsed
|
return parsed
|
||||||
for fmt in _TIMESTAMP_FORMATS:
|
for fmt in _TIMESTAMP_FORMATS:
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(text, fmt)
|
return _as_utc(datetime.strptime(text, fmt))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|||||||
14
tests/_bootstrap.py
Normal file
14
tests/_bootstrap.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"""测试引导模块。
|
||||||
|
|
||||||
|
将 ``src`` 目录加入 ``sys.path``,使 ``python -m unittest discover -s tests -v``
|
||||||
|
在未安装包的情况下也能直接运行(src-layout 项目无需额外设置 PYTHONPATH)。
|
||||||
|
每个测试模块顶部 ``import _bootstrap`` 即可。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src")
|
||||||
|
if _SRC not in sys.path:
|
||||||
|
sys.path.insert(0, _SRC)
|
||||||
42
tests/test_config.py
Normal file
42
tests/test_config.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import _bootstrap # noqa: F401
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fault_log_analyzer.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig(unittest.TestCase):
|
||||||
|
def test_defaults(self):
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
cfg = Config.from_env()
|
||||||
|
self.assertEqual(cfg.kafka_logs_raw_topic, "logs.raw")
|
||||||
|
self.assertEqual(cfg.kafka_logs_fault_topic, "logs.fault")
|
||||||
|
self.assertEqual(cfg.cluster_eps, 0.75)
|
||||||
|
self.assertEqual(cfg.cluster_min_samples, 5)
|
||||||
|
self.assertEqual(cfg.storage, "memory")
|
||||||
|
self.assertEqual(cfg.api_port, 8080)
|
||||||
|
|
||||||
|
def test_env_overrides(self):
|
||||||
|
env = {
|
||||||
|
"KAFKA_LOGS_RAW_TOPIC": "my.logs",
|
||||||
|
"CLUSTER_EPS": "0.5",
|
||||||
|
"CLUSTER_MIN_SAMPLES": "3",
|
||||||
|
"API_PORT": "9090",
|
||||||
|
"STORAGE": "mysql",
|
||||||
|
"ES_HOSTS": "http://es1:9200, http://es2:9200",
|
||||||
|
"CLUSTER_USE_SKLEARN": "true",
|
||||||
|
}
|
||||||
|
with patch.dict("os.environ", env, clear=True):
|
||||||
|
cfg = Config.from_env()
|
||||||
|
self.assertEqual(cfg.kafka_logs_raw_topic, "my.logs")
|
||||||
|
self.assertEqual(cfg.cluster_eps, 0.5)
|
||||||
|
self.assertEqual(cfg.cluster_min_samples, 3)
|
||||||
|
self.assertEqual(cfg.api_port, 9090)
|
||||||
|
self.assertEqual(cfg.storage, "mysql")
|
||||||
|
self.assertEqual(cfg.es_hosts, ["http://es1:9200", "http://es2:9200"])
|
||||||
|
self.assertTrue(cfg.cluster_use_sklearn)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
58
tests/test_filters.py
Normal file
58
tests/test_filters.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import _bootstrap # noqa: F401
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fault_log_analyzer.filters import CaptureFilter
|
||||||
|
from fault_log_analyzer.models import FaultFilterRule, LogEntry
|
||||||
|
|
||||||
|
|
||||||
|
def entry(level="ERROR", message="something", host_id="h-1"):
|
||||||
|
return LogEntry(
|
||||||
|
timestamp=datetime(2025, 1, 1, tzinfo=timezone.utc),
|
||||||
|
level=level,
|
||||||
|
message=message,
|
||||||
|
host_id=host_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureFilter(unittest.TestCase):
|
||||||
|
def test_default_levels(self):
|
||||||
|
f = CaptureFilter()
|
||||||
|
self.assertTrue(f.is_fault(entry(level="ERROR")))
|
||||||
|
self.assertTrue(f.is_fault(entry(level="FATAL")))
|
||||||
|
self.assertTrue(f.is_fault(entry(level="CRITICAL")))
|
||||||
|
self.assertFalse(f.is_fault(entry(level="WARN")))
|
||||||
|
self.assertFalse(f.is_fault(entry(level="INFO")))
|
||||||
|
|
||||||
|
def test_rule_pattern_hit(self):
|
||||||
|
f = CaptureFilter([FaultFilterRule(name="disk", level="ERROR", pattern="disk full")])
|
||||||
|
self.assertTrue(f.is_fault(entry(message="No space, disk full on device")))
|
||||||
|
self.assertFalse(f.is_fault(entry(message="connection refused")))
|
||||||
|
|
||||||
|
def test_rule_exclude_pattern(self):
|
||||||
|
f = CaptureFilter(
|
||||||
|
[FaultFilterRule(name="disk", level="ERROR", pattern="disk", exclude_pattern="ignore")]
|
||||||
|
)
|
||||||
|
self.assertTrue(f.is_fault(entry(message="disk full")))
|
||||||
|
self.assertFalse(f.is_fault(entry(message="disk ignore")))
|
||||||
|
|
||||||
|
def test_rule_level_mismatch(self):
|
||||||
|
f = CaptureFilter([FaultFilterRule(name="disk", level="FATAL", pattern="disk")])
|
||||||
|
self.assertFalse(f.is_fault(entry(level="ERROR", message="disk full")))
|
||||||
|
|
||||||
|
def test_configured_rules_do_not_fallback(self):
|
||||||
|
# 配置了规则但均未命中时不走默认级别兜底
|
||||||
|
f = CaptureFilter([FaultFilterRule(name="disk", level="ERROR", pattern="disk full")])
|
||||||
|
self.assertFalse(f.is_fault(entry(level="FATAL", message="connection refused")))
|
||||||
|
|
||||||
|
def test_disabled_rule_falls_back_to_default(self):
|
||||||
|
f = CaptureFilter(
|
||||||
|
[FaultFilterRule(name="disk", level="ERROR", pattern="disk", enabled=False)]
|
||||||
|
)
|
||||||
|
self.assertTrue(f.is_fault(entry(level="ERROR", message="anything")))
|
||||||
|
self.assertFalse(f.is_fault(entry(level="INFO", message="anything")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
62
tests/test_fingerprint.py
Normal file
62
tests/test_fingerprint.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import _bootstrap # noqa: F401
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fault_log_analyzer.fingerprint import (
|
||||||
|
MinHash,
|
||||||
|
message_fingerprint,
|
||||||
|
sha1_hex,
|
||||||
|
shingles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFingerprint(unittest.TestCase):
|
||||||
|
def test_sha1_hex_length(self):
|
||||||
|
self.assertEqual(len(sha1_hex("abc", 16)), 16)
|
||||||
|
|
||||||
|
def test_same_template_same_fingerprint(self):
|
||||||
|
f1 = message_fingerprint("Connection from 10.0.0.11 failed after 3 retries")
|
||||||
|
f2 = message_fingerprint("Connection from 10.0.0.12 failed after 5 retries")
|
||||||
|
self.assertEqual(f1, f2)
|
||||||
|
|
||||||
|
def test_different_message_different_fingerprint(self):
|
||||||
|
self.assertNotEqual(message_fingerprint("disk full"), message_fingerprint("connection refused"))
|
||||||
|
|
||||||
|
def test_shingles(self):
|
||||||
|
s = shingles(["a", "b", "c", "d"], k=3)
|
||||||
|
self.assertIn("a|b|c", s)
|
||||||
|
self.assertIn("b|c|d", s)
|
||||||
|
self.assertNotIn("c|d|e", s)
|
||||||
|
|
||||||
|
def test_shingles_short_sequence(self):
|
||||||
|
s = shingles(["a", "b"], k=3)
|
||||||
|
self.assertIn("a|b", s)
|
||||||
|
|
||||||
|
def test_shingles_empty(self):
|
||||||
|
self.assertEqual(shingles([], k=3), set())
|
||||||
|
|
||||||
|
|
||||||
|
class TestMinHash(unittest.TestCase):
|
||||||
|
def test_signature_length(self):
|
||||||
|
mh = MinHash(num_hashes=16)
|
||||||
|
sig = mh.signature(["a", "b", "c"])
|
||||||
|
self.assertEqual(len(sig), 16)
|
||||||
|
|
||||||
|
def test_signature_empty(self):
|
||||||
|
mh = MinHash(num_hashes=8)
|
||||||
|
sig = mh.signature([])
|
||||||
|
self.assertEqual(sig, [0] * 8)
|
||||||
|
|
||||||
|
def test_jaccard_estimate_identical(self):
|
||||||
|
mh = MinHash(num_hashes=32)
|
||||||
|
sig = mh.signature(["a", "b", "c", "d"])
|
||||||
|
self.assertEqual(mh.jaccard_estimate(sig, sig), 1.0)
|
||||||
|
|
||||||
|
def test_jaccard_estimate_length_mismatch(self):
|
||||||
|
mh = MinHash(num_hashes=8)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
mh.jaccard_estimate([1, 2], [1, 2, 3])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
86
tests/test_models.py
Normal file
86
tests/test_models.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import _bootstrap # noqa: F401
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fault_log_analyzer import models
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsoHelpers(unittest.TestCase):
|
||||||
|
def test_to_iso_naive(self):
|
||||||
|
dt = datetime(2025, 1, 1, 10, 0, 0)
|
||||||
|
self.assertEqual(models.to_iso(dt), "2025-01-01T10:00:00Z")
|
||||||
|
|
||||||
|
def test_from_iso_z(self):
|
||||||
|
dt = models.from_iso("2025-01-01T10:00:00Z")
|
||||||
|
self.assertEqual(dt.year, 2025)
|
||||||
|
self.assertIsNotNone(dt.tzinfo)
|
||||||
|
|
||||||
|
def test_from_iso_invalid_returns_none(self):
|
||||||
|
self.assertIsNone(models.from_iso("not-a-date"))
|
||||||
|
self.assertIsNone(models.from_iso(None))
|
||||||
|
self.assertIsNone(models.from_iso(""))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFaultLogRoundTrip(unittest.TestCase):
|
||||||
|
def test_roundtrip(self):
|
||||||
|
fl = models.FaultLog(
|
||||||
|
fault_log_id="fl-1",
|
||||||
|
host_id="h-1",
|
||||||
|
fingerprint="fp",
|
||||||
|
level="ERROR",
|
||||||
|
message="disk full",
|
||||||
|
occurred_at=datetime(2025, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
|
||||||
|
fault_type="disk_full",
|
||||||
|
cluster_id="c-1",
|
||||||
|
service="app",
|
||||||
|
trace_id="t-1",
|
||||||
|
count=3,
|
||||||
|
)
|
||||||
|
d = fl.to_dict()
|
||||||
|
fl2 = models.FaultLog.from_dict(d)
|
||||||
|
self.assertEqual(fl2.fault_log_id, "fl-1")
|
||||||
|
self.assertEqual(fl2.host_id, "h-1")
|
||||||
|
self.assertEqual(fl2.count, 3)
|
||||||
|
self.assertEqual(fl2.occurred_at.tzinfo is not None, True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRootCauseRoundTrip(unittest.TestCase):
|
||||||
|
def test_roundtrip_with_evidence(self):
|
||||||
|
rc = models.RootCause(
|
||||||
|
fault_log_id="fl-1",
|
||||||
|
cause_type="disk_full",
|
||||||
|
confidence=0.92,
|
||||||
|
evidence=[
|
||||||
|
models.Evidence(type="event", event_id="e-1", metric="disk_used_percent", value=97.2),
|
||||||
|
models.Evidence(type="log", message="No space left on device"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
d = rc.to_dict()
|
||||||
|
rc2 = models.RootCause.from_dict(d)
|
||||||
|
self.assertEqual(rc2.fault_log_id, "fl-1")
|
||||||
|
self.assertEqual(rc2.cause_type, "disk_full")
|
||||||
|
self.assertEqual(len(rc2.evidence), 2)
|
||||||
|
self.assertEqual(rc2.evidence[0].event_id, "e-1")
|
||||||
|
|
||||||
|
|
||||||
|
class TestOtherModelRoundTrips(unittest.TestCase):
|
||||||
|
def test_fault_type(self):
|
||||||
|
ft = models.FaultType(fault_type="disk_full", name="磁盘满", pattern="disk full", severity="critical")
|
||||||
|
self.assertEqual(models.FaultType.from_dict(ft.to_dict()).fault_type, "disk_full")
|
||||||
|
|
||||||
|
def test_filter_rule(self):
|
||||||
|
r = models.FaultFilterRule(name="disk", level="ERROR", pattern="disk", exclude_pattern="ignore")
|
||||||
|
self.assertEqual(models.FaultFilterRule.from_dict(r.to_dict()).pattern, "disk")
|
||||||
|
|
||||||
|
def test_event(self):
|
||||||
|
e = models.Event(event_id="e-1", host_id="h-1", metric="cpu_usage", value=99.0)
|
||||||
|
self.assertEqual(models.Event.from_dict(e.to_dict()).value, 99.0)
|
||||||
|
|
||||||
|
def test_log_entry(self):
|
||||||
|
le = models.LogEntry(timestamp=datetime(2025, 1, 1, tzinfo=timezone.utc), level="ERROR", message="x")
|
||||||
|
self.assertEqual(models.LogEntry.from_dict(le.to_dict()).message, "x")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@ -1,5 +1,6 @@
|
|||||||
|
import _bootstrap # noqa: F401 (将 src 加入 sys.path)
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fault_log_analyzer.parser import parse_log, parse_timestamp, templatize, tokenize
|
from fault_log_analyzer.parser import parse_log, parse_timestamp, templatize, tokenize
|
||||||
|
|
||||||
@ -9,14 +10,21 @@ class TestParseTimestamp(unittest.TestCase):
|
|||||||
dt = parse_timestamp("2025-01-01T10:18:00Z")
|
dt = parse_timestamp("2025-01-01T10:18:00Z")
|
||||||
self.assertEqual(dt.year, 2025)
|
self.assertEqual(dt.year, 2025)
|
||||||
self.assertEqual(dt.minute, 18)
|
self.assertEqual(dt.minute, 18)
|
||||||
|
self.assertIsNotNone(dt.tzinfo)
|
||||||
|
|
||||||
def test_epoch_seconds(self):
|
def test_epoch_seconds(self):
|
||||||
dt = parse_timestamp(1735726680) # 2025-01-01T10:18:00Z
|
dt = parse_timestamp(1735726680) # 2025-01-01T10:18:00Z
|
||||||
self.assertEqual(dt.year, 2025)
|
self.assertEqual(dt.year, 2025)
|
||||||
|
self.assertIsNotNone(dt.tzinfo)
|
||||||
|
|
||||||
def test_epoch_millis(self):
|
def test_epoch_millis(self):
|
||||||
dt = parse_timestamp(1735726680000)
|
dt = parse_timestamp(1735726680000)
|
||||||
self.assertEqual(dt.year, 2025)
|
self.assertEqual(dt.year, 2025)
|
||||||
|
self.assertEqual(dt.hour, 10)
|
||||||
|
|
||||||
|
def test_epoch_nanos(self):
|
||||||
|
dt = parse_timestamp(1735726680000000000)
|
||||||
|
self.assertEqual(dt.year, 2025)
|
||||||
|
|
||||||
def test_common_format(self):
|
def test_common_format(self):
|
||||||
dt = parse_timestamp("2025-01-01 10:18:00")
|
dt = parse_timestamp("2025-01-01 10:18:00")
|
||||||
@ -26,6 +34,12 @@ class TestParseTimestamp(unittest.TestCase):
|
|||||||
def test_invalid_returns_none(self):
|
def test_invalid_returns_none(self):
|
||||||
self.assertIsNone(parse_timestamp("not-a-date"))
|
self.assertIsNone(parse_timestamp("not-a-date"))
|
||||||
|
|
||||||
|
def test_datetime_passthrough(self):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
dt = parse_timestamp(datetime(2025, 1, 1, tzinfo=timezone.utc))
|
||||||
|
self.assertEqual(dt.year, 2025)
|
||||||
|
|
||||||
|
|
||||||
class TestParseLog(unittest.TestCase):
|
class TestParseLog(unittest.TestCase):
|
||||||
def test_structured_json(self):
|
def test_structured_json(self):
|
||||||
@ -53,6 +67,16 @@ class TestParseLog(unittest.TestCase):
|
|||||||
self.assertEqual(entry.level, "FATAL")
|
self.assertEqual(entry.level, "FATAL")
|
||||||
self.assertEqual(entry.host_id, "h-2")
|
self.assertEqual(entry.host_id, "h-2")
|
||||||
|
|
||||||
|
def test_fields_fallback(self):
|
||||||
|
raw = {
|
||||||
|
"message": "boom",
|
||||||
|
"fields": {"hostname": "web-01", "service_name": "api", "request_id": "r-9"},
|
||||||
|
}
|
||||||
|
entry = parse_log(raw)
|
||||||
|
self.assertEqual(entry.host_id, "web-01")
|
||||||
|
self.assertEqual(entry.service, "api")
|
||||||
|
self.assertEqual(entry.trace_id, "r-9")
|
||||||
|
|
||||||
|
|
||||||
class TestTemplatize(unittest.TestCase):
|
class TestTemplatize(unittest.TestCase):
|
||||||
def test_replace_variables(self):
|
def test_replace_variables(self):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user