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
316 lines
12 KiB
Python
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)
|