ariadne/tests/test_hermes_autotriage_evidence.py
codex 136caa8477
All checks were successful
Tests / Declarative: Post Actions passed: 1200
feat(hermes): search a service's own namespace for log evidence
Log evidence covered the demo namespace plus jenkins. That is right for the
common case, since CI failures happen in Jenkins agent pods, but it means a
build failure that correlates with the service itself being unhealthy carries
no trace of the service at all.

ARIADNE_HERMES_JOB_NAMESPACES maps a job to its own namespace, which is added
alongside jenkins rather than replacing it. Unmapped jobs are unchanged, and a
namespace already in the list is never duplicated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:32:59 -03:00

375 lines
14 KiB
Python

from __future__ import annotations
from types import SimpleNamespace
from ariadne.services import hermes_autotriage_evidence as module
JOB = "hermes-triage-demo"
INCIDENT_ID = f"{JOB}/12"
def _settings() -> SimpleNamespace:
return SimpleNamespace(
jenkins_base_url="https://ci.example",
jenkins_api_user="user",
jenkins_api_token="token",
jenkins_api_timeout_sec=5.0,
opensearch_url="http://opensearch:9200",
hermes_demo_namespace="hermes-triage-demo",
)
class FakeResponse:
def __init__(self, payload=None, text="") -> None: # type: ignore[no-untyped-def]
self.payload = payload
self.text = text
def raise_for_status(self) -> None:
return None
def json(self): # type: ignore[no-untyped-def]
return self.payload
def _install(monkeypatch, routes, log_result=None) -> dict: # type: ignore[no-untyped-def]
calls: dict = {"gets": [], "log": [], "client_kwargs": None}
class FakeClient:
def __init__(self, **kwargs) -> None: # type: ignore[no-untyped-def]
calls["client_kwargs"] = kwargs
def __enter__(self): # type: ignore[no-untyped-def]
return self
def __exit__(self, *args) -> None: # type: ignore[no-untyped-def]
return None
def get(self, url, params=None): # type: ignore[no-untyped-def]
calls["gets"].append((url, params))
for suffix, item in routes.items():
if suffix in url:
if isinstance(item, Exception):
raise item
return item
raise AssertionError(f"unexpected url {url}")
def fake_logs(config, incident_id, window_start, window_end): # type: ignore[no-untyped-def]
calls["log"].append((config, incident_id, window_start, window_end))
if log_result is not None:
return log_result
return {"query_window": {}, "records": [], "truncated": False, "error": None}
monkeypatch.setattr(module, "settings", _settings())
monkeypatch.setattr(module.httpx, "Client", FakeClient)
monkeypatch.setattr(module, "collect_log_evidence", fake_logs)
return calls
def _last_build(**overrides): # type: ignore[no-untyped-def]
payload = {
"number": 12,
"result": "FAILURE",
"building": False,
"timestamp": 1720000000000,
"duration": 60000,
"url": f"https://ci.example/job/{JOB}/12/",
}
payload.update(overrides)
return payload
def _all_failing_routes() -> dict:
return {
"/wfapi/describe": RuntimeError("stage fetch failed"),
"/testReport/api/json": RuntimeError("no test report"),
"/consoleText": RuntimeError("no console"),
}
def test_collect_evidence_full_bundle(monkeypatch) -> None:
routes = {
"/wfapi/describe": FakeResponse(
{"stages": [{"name": "Build", "status": "SUCCESS"}, {"name": "Test", "status": "FAILED"}]}
),
"/testReport/api/json": FakeResponse(
{
"suites": [
{
"cases": [
{
"name": "fixture-state-check",
"className": "demo.Fixture",
"status": "FAILED",
"errorDetails": "boom",
},
{"name": "ok-test", "className": "demo.Ok", "status": "PASSED"},
{
"name": "flaky",
"className": "demo.Flaky",
"status": "REGRESSION",
"errorDetails": 5,
},
]
}
]
}
),
"/consoleText": FakeResponse(text="line1\nline2\nhermes_demo_test_failure seen"),
}
calls = _install(monkeypatch, routes)
bundle = module.collect_evidence(INCIDENT_ID, JOB, _last_build())
assert bundle["incident_id"] == INCIDENT_ID
assert bundle["generated_at"]
jenkins = bundle["jenkins"]
assert jenkins["job"] == JOB
assert jenkins["build_number"] == 12
assert jenkins["result"] == "FAILURE"
assert jenkins["url"] == f"https://ci.example/job/{JOB}/12/"
assert jenkins["duration_seconds"] == 60.0
assert jenkins["timestamps"] == {
"start": "2024-07-03T09:46:40+00:00",
"end": "2024-07-03T09:47:40+00:00",
}
assert jenkins["first_failed_stage"] == "Test"
assert jenkins["failed_tests"] == [
{"name": "fixture-state-check", "className": "demo.Fixture", "errorDetails": "boom"},
{"name": "flaky", "className": "demo.Flaky", "errorDetails": None},
]
assert jenkins["console_tail"] == "line1\nline2\nhermes_demo_test_failure seen"
assert jenkins["console_failures"] == []
assert jenkins["console_truncated"] is False
assert bundle["log_evidence"]["records"] == []
config, incident_id, window_start, window_end = calls["log"][0]
assert incident_id == INCIDENT_ID
assert (window_start, window_end) == (jenkins["timestamps"]["start"], jenkins["timestamps"]["end"])
assert config == {
"opensearch_url": "http://opensearch:9200",
"namespace": "hermes-triage-demo",
"extra_namespaces": ["jenkins"],
}
assert calls["client_kwargs"]["auth"] == ("user", "token")
def test_collect_evidence_tolerates_all_fetch_failures(monkeypatch) -> None:
_install(monkeypatch, _all_failing_routes())
bundle = module.collect_evidence(INCIDENT_ID, JOB, _last_build())
jenkins = bundle["jenkins"]
assert jenkins["first_failed_stage"] is None
assert jenkins["failed_tests"] == []
assert jenkins["console_tail"] is None
assert bundle["log_evidence"]["error"] is None
def test_collect_evidence_tolerates_client_failure(monkeypatch) -> None:
_install(monkeypatch, {})
def boom(**kwargs): # type: ignore[no-untyped-def]
raise RuntimeError("no client")
monkeypatch.setattr(module.httpx, "Client", boom)
bundle = module.collect_evidence(INCIDENT_ID, JOB, _last_build())
assert bundle["jenkins"]["failed_tests"] == []
assert bundle["jenkins"]["console_tail"] is None
assert "log_evidence" in bundle
def test_console_tail_caps_lines_and_bytes(monkeypatch) -> None:
routes = _all_failing_routes()
routes["/consoleText"] = FakeResponse(text="\n".join(f"line-{i}" for i in range(300)))
_install(monkeypatch, routes)
tail = module.collect_evidence(INCIDENT_ID, JOB, _last_build())["jenkins"]["console_tail"]
lines = tail.splitlines()
assert len(lines) == 40
assert lines[0] == "line-260"
assert lines[-1] == "line-299"
routes["/consoleText"] = FakeResponse(text="x" * 20000)
_install(monkeypatch, routes)
tail = module.collect_evidence(INCIDENT_ID, JOB, _last_build())["jenkins"]["console_tail"]
assert len(tail) == 4000
def _long_console() -> str:
head = [
"[Pipeline] stage: Unit Tests",
"+ python -m pytest tests -q",
"=================================== FAILURES ===================================",
"E assert 0 == 100",
"FAILED tests/test_ledger.py::test_balance",
]
noise = [f"[Pipeline] teardown {i} completed ok" for i in range(2000)]
tail = [
"java.io.IOException: Failed to archive artifacts",
"ERROR: script returned exit code 1",
"Finished: FAILURE",
]
return "\n".join(head + noise + tail)
def test_console_failure_regions_capture_the_early_failure(monkeypatch) -> None:
routes = _all_failing_routes()
routes["/consoleText"] = FakeResponse(text=_long_console())
_install(monkeypatch, routes)
jenkins = module.collect_evidence(INCIDENT_ID, JOB, _last_build())["jenkins"]
regions = jenkins["console_failures"]
assert regions
assert "FAILED tests/test_ledger.py::test_balance" in regions[0]["text"]
assert regions[0]["line_number"] <= 5
assert set(regions[0]) == {"marker", "line_number", "text"}
assert "FAILED tests/test_ledger.py::test_balance" not in jenkins["console_tail"]
assert "ERROR: script returned exit code 1" in jenkins["console_tail"]
assert jenkins["console_truncated"] is False
def test_console_read_is_bounded_but_keeps_head_and_tail(monkeypatch) -> None:
text = "\n".join(
["ERROR: early boom"] + ["filler line that is long enough to add up" * 3] * 60000 + ["ERROR: late boom"]
)
assert len(text) > module._CONSOLE_MAX_CHARS
routes = _all_failing_routes()
routes["/consoleText"] = FakeResponse(text=text)
_install(monkeypatch, routes)
jenkins = module.collect_evidence(INCIDENT_ID, JOB, _last_build())["jenkins"]
assert "ERROR: early boom" in jenkins["console_failures"][0]["text"]
assert "ERROR: late boom" in jenkins["console_tail"]
def test_failed_tests_are_capped_and_error_details_truncated(monkeypatch) -> None:
cases = [
{"name": f"t{i}", "className": "demo.Case", "status": "FAILED", "errorDetails": "x" * 5000}
for i in range(15)
]
routes = _all_failing_routes()
routes["/testReport/api/json"] = FakeResponse({"suites": [{"cases": cases}]})
_install(monkeypatch, routes)
failed = module.collect_evidence(INCIDENT_ID, JOB, _last_build())["jenkins"]["failed_tests"]
assert len(failed) == 10
assert all(len(test["errorDetails"]) == 2000 for test in failed)
def test_window_falls_back_when_timestamp_missing(monkeypatch) -> None:
_install(monkeypatch, _all_failing_routes())
bundle = module.collect_evidence(INCIDENT_ID, JOB, _last_build(timestamp=None, duration=None))
timestamps = bundle["jenkins"]["timestamps"]
assert timestamps["start"] == timestamps["end"]
assert bundle["jenkins"]["duration_seconds"] == 0.0
def test_window_end_uses_now_when_duration_missing(monkeypatch) -> None:
_install(monkeypatch, _all_failing_routes())
bundle = module.collect_evidence(INCIDENT_ID, JOB, _last_build(duration=0))
timestamps = bundle["jenkins"]["timestamps"]
assert timestamps["start"] == "2024-07-03T09:46:40+00:00"
assert timestamps["end"] > timestamps["start"]
def test_evidence_tolerates_odd_jenkins_payloads(monkeypatch) -> None:
routes = {
"/wfapi/describe": FakeResponse({"stages": [{"name": "Build", "status": "SUCCESS"}]}),
"/testReport/api/json": FakeResponse(
{"suites": ["bad", {"cases": [123, {"name": "t", "className": "c", "status": "FAILED"}]}]}
),
"/consoleText": RuntimeError("no console"),
}
_install(monkeypatch, routes)
bundle = module.collect_evidence(INCIDENT_ID, JOB, _last_build(number="not-a-number"))
jenkins = bundle["jenkins"]
assert jenkins["build_number"] == 0
assert jenkins["first_failed_stage"] is None
assert jenkins["failed_tests"] == [{"name": "t", "className": "c", "errorDetails": None}]
def _bundle(failed_tests=(), console_tail="", records=(), console_failures=()): # type: ignore[no-untyped-def]
return {
"jenkins": {
"failed_tests": list(failed_tests),
"console_tail": console_tail,
"console_failures": list(console_failures),
},
"log_evidence": {"records": list(records)},
}
def test_signature_from_console_failure_region() -> None:
bundle = _bundle(
console_tail="ERROR: script returned exit code 1",
console_failures=[
{"marker": "FAILED ", "line_number": 12, "text": "FAILED hermes_demo_test_failure check"},
"not-a-region",
],
)
assert module.evidence_has_signature(bundle, INCIDENT_ID) is True
def test_signature_from_failed_test_name() -> None:
bundle = _bundle(failed_tests=[{"name": "fixture-state-check", "className": "c", "errorDetails": None}])
assert module.evidence_has_signature(bundle, INCIDENT_ID) is True
def test_signature_from_console_marker() -> None:
bundle = _bundle(console_tail="... hermes_demo_test_failure ...")
assert module.evidence_has_signature(bundle, INCIDENT_ID) is True
def test_signature_from_log_records_with_incident_id() -> None:
bundle = _bundle(
records=[
{"message": "seed hermes_demo_test_failure marker"},
{"message": f"incident {INCIDENT_ID} correlated"},
]
)
assert module.evidence_has_signature(bundle, INCIDENT_ID) is True
def test_signature_marker_without_incident_id_is_not_enough() -> None:
bundle = _bundle(records=[{"message": "hermes_demo_test_failure only"}])
assert module.evidence_has_signature(bundle, INCIDENT_ID) is False
def test_signature_absent() -> None:
bundle = _bundle(
failed_tests=[{"name": "other-test", "className": "c", "errorDetails": None}],
console_tail="all quiet",
records=[{"message": "normal log"}],
)
assert module.evidence_has_signature(bundle, INCIDENT_ID) is False
def test_signature_tolerates_malformed_bundle() -> None:
assert module.evidence_has_signature({}, INCIDENT_ID) is False
def _settings_with(namespaces): # type: ignore[no-untyped-def]
cfg = _settings()
cfg.hermes_job_namespaces = namespaces
return cfg
def test_log_namespaces_always_include_jenkins(monkeypatch) -> None:
"""CI failures happen in Jenkins agent pods, whatever the service."""
monkeypatch.setattr(module, "settings", _settings_with({}))
assert module._log_config("lesavka")["extra_namespaces"] == ["jenkins"]
def test_a_mapped_job_also_searches_its_own_namespace(monkeypatch) -> None:
"""A build failure can correlate with the service itself being unhealthy."""
monkeypatch.setattr(module, "settings", _settings_with({"bstein-dev-home": "bstein-dev-home"}))
spaces = module._log_config("bstein-dev-home")["extra_namespaces"]
assert "bstein-dev-home" in spaces
assert "jenkins" in spaces
def test_an_unmapped_job_adds_nothing(monkeypatch) -> None:
monkeypatch.setattr(module, "settings", _settings_with({"other": "other-ns"}))
assert module._log_config("lesavka")["extra_namespaces"] == ["jenkins"]
def test_a_namespace_is_never_duplicated(monkeypatch) -> None:
monkeypatch.setattr(module, "settings", _settings_with({"j": "jenkins"}))
assert module._log_config("j")["extra_namespaces"].count("jenkins") == 1