"""Logbook anomaly detection for Sentinel diagnostics.""" from __future__ import annotations import subprocess import sys from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any ROOT_DIR = Path(__file__).resolve().parent.parent LOGBOOK_DIR = ROOT_DIR / "logbook" COLLECT_SCRIPT = LOGBOOK_DIR / "collect_logs.py" REPORTS_DIR = LOGBOOK_DIR / "reports" LATEST_REPORT = REPORTS_DIR / "latest.txt" DEFAULT_MAX_LINES = 80 STALE_THRESHOLD = timedelta(hours=36) def _refresh_report(max_lines: int = DEFAULT_MAX_LINES) -> tuple[bool, str | None]: """Invoke the logbook collector to refresh the latest report.""" if not COLLECT_SCRIPT.exists(): return False, f"logbook script missing: {COLLECT_SCRIPT}" try: result = subprocess.run( [sys.executable, str(COLLECT_SCRIPT), "--lines", str(max_lines)], capture_output=True, text=True, check=False, timeout=60, ) except FileNotFoundError: return False, "python interpreter not found to run collect_logs.py" except subprocess.TimeoutExpired: return False, "collect_logs.py timed out" if result.returncode != 0: stderr = result.stderr.strip() stdout = result.stdout.strip() message = stderr or stdout or f"exit status {result.returncode}" return False, f"collect_logs.py failed: {message}" return True, None def _read_latest_report() -> tuple[str | None, str | None]: """Load the most recent logbook report.""" if not LATEST_REPORT.exists(): return None, f"logbook report missing: {LATEST_REPORT}" try: return LATEST_REPORT.read_text(encoding="utf-8"), None except OSError as exc: return None, f"unable to read {LATEST_REPORT}: {exc}" def _extract_metadata(report_text: str) -> dict[str, Any]: """Parse the logbook report for status indicators.""" generated_at: datetime | None = None status_line: str | None = None issues: list[str] = [] local_tz = datetime.now().astimezone().tzinfo for raw_line in report_text.splitlines(): line = raw_line.strip() if not line: continue if generated_at is None and line.startswith("# Logbook report"): # Expect pattern "# Logbook report – 2025-10-17T12:11:46" parts = line.split("–", maxsplit=1) if len(parts) == 2: timestamp_str = parts[1].strip() try: generated_at = datetime.fromisoformat(timestamp_str) if generated_at.tzinfo is None: generated_at = generated_at.replace(tzinfo=local_tz) except ValueError: generated_at = None continue if status_line is None and (line.startswith("⚠️") or line.startswith("✅")): status_line = line if line.startswith("⚠️"): _, _, remainder = line.partition(":") if remainder: issues = [chunk.strip() for chunk in remainder.split(",") if chunk.strip()] break return { "generated_at": generated_at, "status_line": status_line, "issues": issues, } def check_logbook(max_lines: int = DEFAULT_MAX_LINES) -> dict[str, Any]: """Return the latest logbook status for Sentinel diagnostics.""" refreshed, error = _refresh_report(max_lines=max_lines) if not refreshed: return {"status": "ERROR", "error": error} report_text, read_error = _read_latest_report() if report_text is None: return {"status": "ERROR", "error": read_error} metadata = _extract_metadata(report_text) generated_at = metadata["generated_at"] now = datetime.now(timezone.utc) payload: dict[str, Any] = { "report_path": str(LATEST_REPORT), "issues": metadata["issues"], } if generated_at: payload["generated_at"] = generated_at.isoformat() age = now - generated_at.astimezone(timezone.utc) if age > STALE_THRESHOLD: payload["status"] = "STALE" payload["error"] = f"logbook report is {age.total_seconds() / 3600:.1f} hours old" if metadata["status_line"]: payload["status_line"] = metadata["status_line"] return payload else: payload["status"] = "WARN" payload["error"] = "unable to parse generation timestamp from logbook report" if metadata["status_line"]: payload["status_line"] = metadata["status_line"] return payload has_issues = bool(metadata["issues"]) payload["status"] = "ALERT" if has_issues else "OK" if metadata["status_line"]: payload["status_line"] = metadata["status_line"] return payload