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
408 lines
18 KiB
Python
408 lines
18 KiB
Python
"""Tests for :mod:`integrations.hermes.poller`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import integrations.hermes.poller as hermes_poller
|
|
from integrations.hermes.classifier import IncidentClassifier
|
|
from integrations.hermes.incident import LogLevel
|
|
from tests.utils.hermes_logs_helper import hermes_log_fixture
|
|
|
|
_LINES_BURST = [
|
|
"2026-05-12 00:00:00,000 WARNING gateway.platforms.telegram: polling conflict (1/3), retrying",
|
|
"2026-05-12 00:00:10,000 WARNING gateway.platforms.telegram: polling conflict (2/3), retrying",
|
|
"2026-05-12 00:00:20,000 WARNING gateway.platforms.telegram: polling conflict (3/3), retrying",
|
|
"2026-05-12 00:00:30,000 WARNING gateway.platforms.telegram: polling conflict (1/3), retrying",
|
|
"2026-05-12 00:00:40,000 WARNING gateway.platforms.telegram: polling conflict (2/3), retrying",
|
|
]
|
|
|
|
|
|
class TestCursorTokenRoundTrip:
|
|
def test_token_round_trip_preserves_all_fields(self) -> None:
|
|
cursor = hermes_poller.HermesLogCursor(path="/tmp/x.log", device=42, inode=99, offset=1024)
|
|
restored = hermes_poller.HermesLogCursor.from_token(cursor.to_token())
|
|
assert restored == cursor
|
|
|
|
def test_token_round_trip_supports_paths_with_at_sign(self) -> None:
|
|
# The token uses '@' as a separator; a path containing '@' must
|
|
# still round-trip because the parser greedy-matches the path
|
|
# after the final '@'.
|
|
cursor = hermes_poller.HermesLogCursor(
|
|
path="/var/log/user@host.log", device=1, inode=2, offset=3
|
|
)
|
|
restored = hermes_poller.HermesLogCursor.from_token(cursor.to_token())
|
|
assert restored == cursor
|
|
|
|
def test_malformed_token_raises(self) -> None:
|
|
with pytest.raises(ValueError):
|
|
hermes_poller.HermesLogCursor.from_token("not-a-cursor")
|
|
|
|
|
|
class TestValidateExpectedLogPath:
|
|
def test_accepts_matching_paths(self, tmp_path: Path) -> None:
|
|
log = tmp_path / "errors.log"
|
|
log.write_text("x\n", encoding="utf-8")
|
|
cursor = hermes_poller.HermesLogCursor(path=str(log), device=1, inode=2, offset=3)
|
|
cursor.validate_expected_log_path(log)
|
|
cursor.validate_expected_log_path(str(log))
|
|
|
|
def test_rejects_path_mismatch(self, tmp_path: Path) -> None:
|
|
a = tmp_path / "a.log"
|
|
b = tmp_path / "b.log"
|
|
a.write_text("x\n", encoding="utf-8")
|
|
b.write_text("y\n", encoding="utf-8")
|
|
cursor = hermes_poller.HermesLogCursor(path=str(a), device=0, inode=0, offset=0)
|
|
with pytest.raises(ValueError, match="does not refer"):
|
|
cursor.validate_expected_log_path(b)
|
|
|
|
|
|
class TestPollerBasics:
|
|
def test_first_poll_on_empty_file_returns_no_records(self, tmp_path: Path) -> None:
|
|
with hermes_log_fixture(tmp_path) as fixture:
|
|
poll = fixture.poll_once()
|
|
assert poll.records == ()
|
|
assert poll.incidents == ()
|
|
assert not poll.rotation_detected
|
|
|
|
def test_only_new_lines_are_returned_on_subsequent_poll(self, tmp_path: Path) -> None:
|
|
with hermes_log_fixture(tmp_path) as fixture:
|
|
fixture.write_line(_LINES_BURST[0])
|
|
first = fixture.poll_once()
|
|
assert len(first.records) == 1
|
|
assert first.records[0].logger == "gateway.platforms.telegram"
|
|
|
|
# Add three more lines and verify the second poll yields
|
|
# exactly those three — NOT the original one again.
|
|
for line in _LINES_BURST[1:4]:
|
|
fixture.write_line(line)
|
|
second = fixture.poll_once()
|
|
assert len(second.records) == 3
|
|
assert all(r.logger == "gateway.platforms.telegram" for r in second.records)
|
|
|
|
def test_classifier_state_persists_across_polls(self, tmp_path: Path) -> None:
|
|
"""Threshold-based incidents must fire across poll boundaries —
|
|
otherwise a fast tailer that polls between every two writes
|
|
would never accumulate enough records to emit a burst."""
|
|
with hermes_log_fixture(tmp_path) as fixture:
|
|
fixture.classifier = IncidentClassifier(
|
|
warning_burst_threshold=3, warning_burst_window_s=60.0
|
|
)
|
|
# Write two warnings + poll → no incident yet
|
|
fixture.write_line(_LINES_BURST[0])
|
|
fixture.write_line(_LINES_BURST[1])
|
|
assert fixture.poll_once().incidents == ()
|
|
# Write the third → burst should fire on this poll
|
|
fixture.write_line(_LINES_BURST[2])
|
|
poll = fixture.poll_once()
|
|
assert len(poll.incidents) == 1
|
|
assert poll.incidents[0].rule == "warning_burst"
|
|
|
|
|
|
class TestLevelFilter:
|
|
def test_level_filter_drops_lines_but_still_classifies_them(self, tmp_path: Path) -> None:
|
|
"""A level filter must not prevent warning_burst from firing —
|
|
the classifier still observes filtered records."""
|
|
with hermes_log_fixture(tmp_path) as fixture:
|
|
fixture.classifier = IncidentClassifier(
|
|
warning_burst_threshold=3, warning_burst_window_s=60.0
|
|
)
|
|
for line in _LINES_BURST[:3]:
|
|
fixture.write_line(line)
|
|
|
|
poll = fixture.poll_once(level_filter=frozenset({LogLevel.ERROR}))
|
|
assert poll.records == (), "WARNING records should be dropped from response"
|
|
assert len(poll.incidents) == 1, "but the classifier should still emit the burst"
|
|
assert poll.incidents[0].rule == "warning_burst"
|
|
|
|
|
|
class TestSinceFilter:
|
|
def test_continuations_are_filtered_with_their_parent(self, tmp_path: Path) -> None:
|
|
p = tmp_path / "errs.log"
|
|
p.write_text(
|
|
"2026-05-12 00:00:00,000 ERROR x: Traceback (most recent call last):\n"
|
|
' File "/x", line 1, in foo\n'
|
|
"2026-05-12 00:00:30,000 ERROR x: later\n",
|
|
encoding="utf-8",
|
|
)
|
|
since = datetime(2026, 5, 12, 0, 0, 10)
|
|
poll = hermes_poller.poll_hermes_logs(
|
|
p,
|
|
hermes_poller.HermesLogCursor.at_start(p),
|
|
classifier=IncidentClassifier(),
|
|
since=since,
|
|
)
|
|
assert [r.message for r in poll.records] == ["later"]
|
|
assert all(not r.is_continuation for r in poll.records)
|
|
|
|
def test_continuations_survive_when_parent_passes_since(self, tmp_path: Path) -> None:
|
|
p = tmp_path / "errs.log"
|
|
p.write_text(
|
|
"2026-05-12 00:00:20,000 ERROR x: Traceback (most recent call last):\n"
|
|
' File "/x", line 1, in foo\n',
|
|
encoding="utf-8",
|
|
)
|
|
since = datetime(2026, 5, 12, 0, 0, 10)
|
|
poll = hermes_poller.poll_hermes_logs(
|
|
p,
|
|
hermes_poller.HermesLogCursor.at_start(p),
|
|
classifier=IncidentClassifier(),
|
|
since=since,
|
|
)
|
|
assert len(poll.records) == 2
|
|
assert poll.records[0].is_continuation is False
|
|
assert poll.records[1].is_continuation is True
|
|
|
|
def test_interleaved_loggers_continuations_follow_own_parent(self, tmp_path: Path) -> None:
|
|
"""Regression: scalar parent_passes_since was overwritten by each
|
|
non-continuation record regardless of logger. When logger-B (filtered)
|
|
appears between logger-A's header and a later logger-A record, the
|
|
*next* non-header record after logger-B inherits logger-B's decision.
|
|
|
|
Real Python logging emits each exception traceback atomically, so the
|
|
realistic interleaving is: a non-continuation from a filtered logger
|
|
appearing BETWEEN two non-continuation records of a passing logger.
|
|
The second logger-A record falls back to logger-A's own saved decision,
|
|
not logger-B's more recent one.
|
|
|
|
Layout (since = 10s):
|
|
t=20s logger-A header → passes (>= since)
|
|
t=05s logger-B header → filtered (< since) ← would poison scalar
|
|
t=25s logger-A record → should pass (logger-A's own history)
|
|
"""
|
|
p = tmp_path / "interleaved.log"
|
|
p.write_text(
|
|
"2026-05-12 00:00:20,000 ERROR logger-A: first message\n"
|
|
"2026-05-12 00:00:05,000 ERROR logger-B: old message\n"
|
|
"2026-05-12 00:00:25,000 ERROR logger-A: second message\n",
|
|
encoding="utf-8",
|
|
)
|
|
since = datetime(2026, 5, 12, 0, 0, 10)
|
|
poll = hermes_poller.poll_hermes_logs(
|
|
p,
|
|
hermes_poller.HermesLogCursor.at_start(p),
|
|
classifier=IncidentClassifier(),
|
|
since=since,
|
|
)
|
|
messages = [r.message for r in poll.records if not r.is_continuation]
|
|
# logger-B (t=05s) is before since, logger-A records (t=20s, t=25s) pass
|
|
assert "first message" in messages, "logger-A t=20s must pass since filter"
|
|
assert "second message" in messages, "logger-A t=25s must pass since filter"
|
|
assert "old message" not in messages, "logger-B t=05s must be excluded"
|
|
|
|
def test_continuation_follows_traceback_header_not_interleaved_logger(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
"""Regression: a continuation frame must inherit the filter decision of
|
|
its own parent header, not a different logger that wrote between the
|
|
header and the continuation.
|
|
|
|
Layout (since = 10s):
|
|
t=20s logger-A: Traceback … → passes
|
|
t=05s logger-B: plain line → filtered ← old code poisons scalar
|
|
continuation frame → must still pass (parent was logger-A)
|
|
"""
|
|
p = tmp_path / "interleaved2.log"
|
|
p.write_text(
|
|
"2026-05-12 00:00:20,000 ERROR logger-A: Traceback (most recent call last):\n"
|
|
"2026-05-12 00:00:05,000 ERROR logger-B: unrelated\n"
|
|
' File "/a.py", line 1, in foo\n',
|
|
encoding="utf-8",
|
|
)
|
|
since = datetime(2026, 5, 12, 0, 0, 10)
|
|
poll = hermes_poller.poll_hermes_logs(
|
|
p,
|
|
hermes_poller.HermesLogCursor.at_start(p),
|
|
classifier=IncidentClassifier(),
|
|
since=since,
|
|
)
|
|
continuations = [r for r in poll.records if r.is_continuation]
|
|
assert len(continuations) >= 1, (
|
|
"continuation after logger-A header must not be dropped because "
|
|
"logger-B (filtered) appeared between header and continuation"
|
|
)
|
|
|
|
def test_filtered_pre_since_line_before_traceback_does_not_drop_frames(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
"""A filtered non-traceback line before a passing Traceback must not sit
|
|
in ``since_queue`` and poison the first frame's ``passes_since``."""
|
|
p = tmp_path / "since_traceback.log"
|
|
p.write_text(
|
|
"2026-05-12 00:00:05,000 ERROR stale: noise before since window\n"
|
|
"2026-05-12 00:00:20,000 ERROR logger: Traceback (most recent call last):\n"
|
|
' File "/x.py", line 1, in y\n',
|
|
encoding="utf-8",
|
|
)
|
|
since = datetime(2026, 5, 12, 0, 0, 10)
|
|
poll = hermes_poller.poll_hermes_logs(
|
|
p,
|
|
hermes_poller.HermesLogCursor.at_start(p),
|
|
classifier=IncidentClassifier(),
|
|
since=since,
|
|
)
|
|
assert any(r.is_continuation and "x.py" in r.message for r in poll.records), (
|
|
"traceback frame must appear in returned records after a filtered pre-since line"
|
|
)
|
|
|
|
|
|
class TestRotationAndTruncation:
|
|
def test_rotation_resets_offset_and_flags_detection(self, tmp_path: Path) -> None:
|
|
"""logrotate-style rotation: the original file is renamed
|
|
(inode survives, attached to the rotated copy) and a fresh
|
|
file with a NEW inode is created at the original path."""
|
|
with hermes_log_fixture(tmp_path) as fixture:
|
|
fixture.write_line(_LINES_BURST[0])
|
|
first = fixture.poll_once()
|
|
assert len(first.records) == 1
|
|
|
|
# Move the current file aside (preserves its inode on the
|
|
# rotated copy) and create a fresh file at the original
|
|
# path. ``os.rename`` is what logrotate actually does.
|
|
rotated = fixture.path.with_suffix(".log.1")
|
|
import os as _os
|
|
|
|
_os.rename(fixture.path, rotated)
|
|
fixture.path.touch()
|
|
fixture.write_line(_LINES_BURST[1])
|
|
|
|
second = fixture.poll_once()
|
|
assert second.rotation_detected, "poll did not detect inode change after rotation"
|
|
assert len(second.records) == 1
|
|
assert "(2/3)" in second.records[0].message
|
|
|
|
def test_truncation_to_shorter_file_rewinds(self, tmp_path: Path) -> None:
|
|
with hermes_log_fixture(tmp_path) as fixture:
|
|
for line in _LINES_BURST[:3]:
|
|
fixture.write_line(line)
|
|
fixture.poll_once()
|
|
|
|
# Truncate in place (keeps the same inode) and write a
|
|
# single replacement line. The poller should treat this
|
|
# as 'file shrank below my offset' and rewind.
|
|
fixture.path.write_text("", encoding="utf-8")
|
|
fixture.write_line(_LINES_BURST[4])
|
|
|
|
second = fixture.poll_once()
|
|
assert second.rotation_detected, "truncation should trigger rewind"
|
|
assert len(second.records) == 1
|
|
assert "(2/3)" in second.records[0].message # the [4] line
|
|
|
|
|
|
class TestMaxLines:
|
|
def test_max_lines_caps_response_and_reports_truncation(self, tmp_path: Path) -> None:
|
|
with hermes_log_fixture(tmp_path) as fixture:
|
|
for line in _LINES_BURST:
|
|
fixture.write_line(line)
|
|
poll = fixture.poll_once(max_lines=2)
|
|
assert len(poll.records) == 2
|
|
# truncated_lines must be ≥1: at least one line was not returned.
|
|
assert poll.truncated_lines >= 1
|
|
# The cursor must be rewound before the first uncapped line so
|
|
# the caller can drain the rest on the next poll.
|
|
assert poll.cursor.offset < fixture.path.stat().st_size
|
|
|
|
def test_max_lines_cursor_resumes_from_cap_point(self, tmp_path: Path) -> None:
|
|
with hermes_log_fixture(tmp_path) as fixture:
|
|
for line in _LINES_BURST:
|
|
fixture.write_line(line)
|
|
first = fixture.poll_once(max_lines=2)
|
|
assert len(first.records) == 2
|
|
# Second poll must pick up the remaining lines.
|
|
second = fixture.poll_once(max_lines=10)
|
|
assert len(second.records) == 3
|
|
assert second.truncated_lines == 0
|
|
|
|
def test_max_lines_boundary_line_not_double_classified_across_polls(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
"""The line deferred by max_lines must not be classifier.observe'd before
|
|
rewind — a fresh classifier on the next poll would emit duplicate incidents."""
|
|
p = tmp_path / "errs.log"
|
|
p.write_text(
|
|
"2026-05-12 00:00:00,000 ERROR a: one\n"
|
|
"2026-05-12 00:00:01,000 ERROR b: two\n"
|
|
"2026-05-12 00:00:02,000 ERROR c: three\n",
|
|
encoding="utf-8",
|
|
)
|
|
first = hermes_poller.poll_hermes_logs(
|
|
p,
|
|
hermes_poller.HermesLogCursor.at_start(p),
|
|
max_lines=2,
|
|
classifier=IncidentClassifier(),
|
|
)
|
|
assert len(first.records) == 2
|
|
second = hermes_poller.poll_hermes_logs(
|
|
p,
|
|
first.cursor,
|
|
max_lines=10,
|
|
classifier=IncidentClassifier(),
|
|
)
|
|
assert len(second.records) == 1
|
|
err_incidents = [
|
|
i for i in first.incidents + second.incidents if i.rule == "error_severity"
|
|
]
|
|
assert len(err_incidents) == 3
|
|
assert len({i.fingerprint for i in err_incidents}) == 3
|
|
|
|
|
|
class TestMissingFile:
|
|
def test_missing_file_returns_empty_at_start_cursor(self, tmp_path: Path) -> None:
|
|
"""Polling a path that doesn't exist yet must not raise — the
|
|
opensre hermes watch command relies on this so it can start
|
|
before logs/errors.log appears."""
|
|
ghost = tmp_path / "does-not-exist.log"
|
|
poll = hermes_poller.poll_hermes_logs(ghost, hermes_poller.HermesLogCursor.at_start(ghost))
|
|
assert poll.records == ()
|
|
assert poll.cursor.offset == 0
|
|
|
|
|
|
class TestByteBudgetLineBoundary:
|
|
def test_budget_stops_at_line_boundary_and_resumes_next_poll(
|
|
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""Do not partially consume a line when the byte budget runs out mid-read.
|
|
|
|
Cursor must rewind before that line so the next poll reads it entirely.
|
|
"""
|
|
p = tmp_path / "errs.log"
|
|
line1 = "2026-05-12 00:00:00,000 INFO one: aaa\n"
|
|
line2 = "2026-05-12 00:00:01,000 INFO two: bbb\n"
|
|
p.write_text(line1 + line2, encoding="utf-8")
|
|
b1 = len(line1.encode("utf-8"))
|
|
monkeypatch.setattr(hermes_poller, "_DEFAULT_MAX_BYTES", b1 + 1)
|
|
|
|
first = hermes_poller.poll_hermes_logs(
|
|
p, hermes_poller.HermesLogCursor.at_start(p), classifier=IncidentClassifier()
|
|
)
|
|
assert len(first.records) == 1
|
|
assert first.records[0].message.endswith("aaa")
|
|
assert first.cursor.offset == b1
|
|
assert first.truncated_lines >= 1, (
|
|
"byte-budget stop with unread bytes must set truncated_lines so has_more is true"
|
|
)
|
|
|
|
second = hermes_poller.poll_hermes_logs(p, first.cursor, classifier=IncidentClassifier())
|
|
assert len(second.records) == 1
|
|
assert second.records[0].message.endswith("bbb")
|
|
|
|
|
|
class TestPollUntil:
|
|
def test_poll_until_satisfies_predicate(self, tmp_path: Path) -> None:
|
|
"""End-to-end test of the helper's poll_until loop on a live
|
|
write pattern (no actual threading — write all then poll)."""
|
|
with hermes_log_fixture(tmp_path) as fixture:
|
|
fixture.classifier = IncidentClassifier(
|
|
warning_burst_threshold=3, warning_burst_window_s=60.0
|
|
)
|
|
fixture.write_lines(_LINES_BURST[:3])
|
|
satisfied = fixture.poll_until(
|
|
lambda f: any(i.rule == "warning_burst" for i in f.accumulated_incidents),
|
|
budget_s=1.0,
|
|
)
|
|
assert satisfied
|
|
assert fixture.rule_counts().get("warning_burst") == 1
|