Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

316 lines
12 KiB
Python

"""Tests for :mod:`integrations.hermes.classifier`."""
from __future__ import annotations
from datetime import datetime, timedelta
import pytest
from integrations.hermes.classifier import IncidentClassifier, classify_all
from integrations.hermes.incident import IncidentSeverity, LogLevel, LogRecord
from integrations.hermes.rules import RepeatRule, default_pattern_rules
def _record(
*,
level: LogLevel = LogLevel.WARNING,
logger: str = "gateway.platforms.telegram",
message: str = "polling conflict",
timestamp: datetime | None = None,
is_continuation: bool = False,
run_id: str | None = None,
) -> LogRecord:
return LogRecord(
timestamp=timestamp if timestamp is not None else datetime(2026, 5, 12, 0, 0, 0),
level=level,
logger=logger,
message=message,
raw=f"{logger}: {message}",
run_id=run_id,
is_continuation=is_continuation,
)
class TestErrorSeverityRule:
def test_error_emits_high_severity(self) -> None:
classifier = IncidentClassifier()
record = _record(level=LogLevel.ERROR, logger="root", message="boom")
incidents = classifier.observe(record)
assert len(incidents) == 1
assert incidents[0].rule == "error_severity"
assert incidents[0].severity is IncidentSeverity.HIGH
assert incidents[0].records == (record,)
def test_critical_emits_critical_severity(self) -> None:
classifier = IncidentClassifier()
record = _record(level=LogLevel.CRITICAL, logger="root", message="oops")
incidents = classifier.observe(record)
assert len(incidents) == 1
assert incidents[0].severity is IncidentSeverity.CRITICAL
def test_info_emits_nothing(self) -> None:
classifier = IncidentClassifier()
assert classifier.observe(_record(level=LogLevel.INFO)) == []
assert classifier.observe(_record(level=LogLevel.DEBUG)) == []
def test_error_fingerprint_normalizes_volatile_tokens(self) -> None:
classifier = IncidentClassifier()
a = _record(
level=LogLevel.ERROR,
logger="db.client",
message="failed to connect host=10.0.0.1 port=5432 code=500",
)
b = _record(
level=LogLevel.ERROR,
logger="db.client",
message="failed to connect host=10.0.0.2 port=5432 code=501",
)
ia = classifier.observe(a)[0]
ib = classifier.observe(b)[0]
assert ia.rule == "error_severity"
assert ib.rule == "error_severity"
assert ia.fingerprint == ib.fingerprint
class TestWarningBurstRule:
def test_burst_fires_at_threshold_and_clears(self) -> None:
classifier = IncidentClassifier(warning_burst_threshold=3, warning_burst_window_s=60.0)
base = datetime(2026, 5, 12, 0, 0, 0)
out: list = []
for offset in (0, 10, 20):
out.extend(classifier.observe(_record(timestamp=base + timedelta(seconds=offset))))
assert len(out) == 1
burst = out[0]
assert burst.rule == "warning_burst"
assert burst.severity is IncidentSeverity.MEDIUM
assert len(burst.records) == 3
# Bucket cleared on emit; the next single warning must not re-trigger.
further = classifier.observe(_record(timestamp=base + timedelta(seconds=30)))
assert further == []
def test_warnings_outside_window_do_not_accumulate(self) -> None:
classifier = IncidentClassifier(warning_burst_threshold=3, warning_burst_window_s=10.0)
base = datetime(2026, 5, 12, 0, 0, 0)
for offset in (0, 30, 60):
assert classifier.observe(_record(timestamp=base + timedelta(seconds=offset))) == []
def test_burst_segregated_per_logger(self) -> None:
classifier = IncidentClassifier(warning_burst_threshold=3, warning_burst_window_s=60.0)
base = datetime(2026, 5, 12, 0, 0, 0)
out: list = []
for offset, logger in [
(0, "telegram"),
(1, "datadog"),
(2, "telegram"),
(3, "datadog"),
(4, "telegram"),
]:
out.extend(
classifier.observe(
_record(timestamp=base + timedelta(seconds=offset), logger=logger),
)
)
assert len(out) == 1
assert out[0].logger == "telegram"
def test_warnings_without_logger_are_ignored(self) -> None:
classifier = IncidentClassifier(warning_burst_threshold=2)
out = []
for _ in range(5):
out.extend(classifier.observe(_record(logger="")))
assert out == []
def test_repeat_rule_state_is_isolated_per_classifier_instance(self) -> None:
shared_repeat = next(
rule
for rule in default_pattern_rules()
if isinstance(rule, RepeatRule) and rule.name == "crash_loop"
)
c1 = IncidentClassifier(pattern_rules=[shared_repeat], use_default_pattern_rules=False)
c2 = IncidentClassifier(pattern_rules=[shared_repeat], use_default_pattern_rules=False)
base = datetime(2026, 5, 12, 0, 0, 0)
msg = "agent restarted after unexpected exit"
out1 = c1.observe(_record(level=LogLevel.ERROR, message=msg, timestamp=base))
out2 = c1.observe(
_record(
level=LogLevel.ERROR,
message=msg,
timestamp=base + timedelta(seconds=10),
)
)
assert not any(i.rule == "crash_loop" for i in out1)
assert not any(i.rule == "crash_loop" for i in out2)
# If RepeatRule._hits were shared between classifiers, this third
# observation from c2 would fire crash_loop; it must not.
out3 = c2.observe(
_record(
level=LogLevel.ERROR,
message=msg,
timestamp=base + timedelta(seconds=20),
)
)
assert not any(i.rule == "crash_loop" for i in out3)
class TestTracebackRule:
def test_traceback_emitted_when_followup_record_arrives(self) -> None:
classifier = IncidentClassifier(traceback_followup_s=5.0)
parent = _record(
level=LogLevel.ERROR,
logger="tools.terminal_tool",
message="Traceback (most recent call last):",
timestamp=datetime(2026, 5, 12, 0, 12, 17),
)
frame = _record(
level=LogLevel.ERROR,
logger="",
message=' File "/path", line 123, in foo',
timestamp=datetime.min,
is_continuation=True,
)
successor = _record(
level=LogLevel.ERROR,
logger="tools.terminal_tool",
message="next error",
timestamp=datetime(2026, 5, 12, 0, 12, 30),
)
# Parent: opens the traceback buffer; must NOT also fire error_severity
# (that would be a duplicate alert for the same exception).
first = classifier.observe(parent)
assert first == [], (
"Traceback header must not emit any immediate incident — "
"the traceback rule handles it when the block is finalized"
)
# Continuation frame: attaches to the open traceback, no emission.
assert classifier.observe(frame) == []
# Successor: closes the traceback (rule="traceback") and emits its own
# error_severity incident (because 'next error' is not a traceback header).
out = classifier.observe(successor)
rules = [i.rule for i in out]
assert "traceback" in rules
traceback = next(i for i in out if i.rule == "traceback")
assert traceback.severity is IncidentSeverity.CRITICAL
assert frame in traceback.records
assert parent in traceback.records
def test_flush_finalizes_pending_traceback(self) -> None:
classifier = IncidentClassifier()
parent = _record(
level=LogLevel.ERROR,
logger="tools.terminal_tool",
message="Traceback (most recent call last):",
)
classifier.observe(parent)
flushed = classifier.flush()
assert len(flushed) == 1
assert flushed[0].rule == "traceback"
def test_traceback_finalized_by_deadline(self) -> None:
classifier = IncidentClassifier(traceback_followup_s=1.0)
parent = _record(
level=LogLevel.ERROR,
logger="tools.terminal_tool",
message="Traceback (most recent call last):",
timestamp=datetime(2026, 5, 12, 0, 0, 0),
)
classifier.observe(parent)
# A new record from a *different* logger past the deadline closes
# the open traceback even though it doesn't match the parent's
# logger.
unrelated = _record(
level=LogLevel.WARNING,
logger="other.logger",
message="something",
timestamp=datetime(2026, 5, 12, 0, 0, 30),
)
out = classifier.observe(unrelated)
assert any(i.rule == "traceback" for i in out)
class TestClassifyAll:
def test_runs_full_stream_and_flushes(self) -> None:
records = [
_record(level=LogLevel.WARNING, timestamp=datetime(2026, 5, 12, 0, 0, i))
for i in range(5)
]
# Non-traceback ERROR so error_severity is expected here.
records.append(
_record(
level=LogLevel.ERROR,
logger="root",
message="Something went wrong (not a traceback header)",
timestamp=datetime(2026, 5, 12, 0, 0, 6),
)
)
records.append(
_record(
level=LogLevel.ERROR,
logger="root",
message="Traceback (most recent call last):",
timestamp=datetime(2026, 5, 12, 0, 0, 7),
)
)
incidents = classify_all(records)
rules = [i.rule for i in incidents]
assert "warning_burst" in rules
assert "error_severity" in rules
# Traceback header must produce a traceback incident but NOT a second
# error_severity incident (the double-alert regression).
assert "traceback" in rules
def test_traceback_header_does_not_double_alert(self) -> None:
"""A record that opens a traceback must not also fire error_severity.
Each Python exception would otherwise produce two separate incidents
(different fingerprints, different correlator buckets) → two Telegram
notifications and two concurrent RCA investigation calls.
"""
classifier = IncidentClassifier()
header = _record(
level=LogLevel.ERROR,
logger="root",
message="Traceback (most recent call last):",
timestamp=datetime(2026, 5, 12, 10, 0, 0),
)
on_header = classifier.observe(header)
flushed = classifier.flush()
all_incidents = on_header + flushed
rules = [i.rule for i in all_incidents]
assert "traceback" in rules
assert "error_severity" not in rules, (
"Traceback header must not also produce error_severity — "
"that would cause duplicate Telegram notifications and RCA calls"
)
class TestValidation:
@pytest.mark.parametrize("threshold", [0, 1])
def test_threshold_below_two_rejected(self, threshold: int) -> None:
with pytest.raises(ValueError, match="threshold"):
IncidentClassifier(warning_burst_threshold=threshold)
def test_window_must_be_positive(self) -> None:
with pytest.raises(ValueError, match="window"):
IncidentClassifier(warning_burst_window_s=0)