"""Rotating JSONL reporter — the primary evidence trail (IMPLEMENTATION_PLAN §5.1). One JSON event per line (spec §8), size-based rotation with ``backups`` kept, and a header line per file carrying the config digest for provenance — so a rotated file can always be traced to the exact thresholds that produced it. Appends are "atomic-ish": one pre-serialized line + newline, flushed. A hard kill can at worst lose the current line, never corrupt earlier ones (event lines are a few hundred bytes — single small appends, not torn writes). """ from __future__ import annotations import hashlib import json from pathlib import Path from opcda_diagnozer.config import Config from opcda_diagnozer.events import DiagEvent def config_digest(config: Config) -> str: """Stable SHA-256 over the effective config (provenance for log headers).""" payload = json.dumps(config, default=lambda o: getattr(o, "__dict__", str(o)), sort_keys=True) return hashlib.sha256(payload.encode("utf-8")).hexdigest() def rotate_file(path: Path, backups: int) -> None: """Size-rotation used by both file reporters: ``p`` -> ``p.1`` … ``p.backups``. The oldest copy is dropped; numbering shifts by one so ``p.1`` is always the most recently closed file. Shared so the JSONL and text reporters guarantee identical retention semantics (spec §9.1). """ if not path.exists(): return for index in range(backups - 1, 0, -1): older = path.with_name(f"{path.name}.{index}") if older.exists(): older.replace(path.with_name(f"{path.name}.{index + 1}")) path.replace(path.with_name(f"{path.name}.1")) class JsonlReporter: """One JSON event per line, rotated by size (spec §9).""" def __init__(self, directory: str | Path, max_mb: int = 50, backups: int = 10, digest: str = "") -> None: self._directory = Path(directory) self._max_bytes = max_mb * 1024 * 1024 self._backups = backups self._digest = digest self._directory.mkdir(parents=True, exist_ok=True) self._current = self._directory / "events.jsonl" def emit(self, event: DiagEvent) -> None: """Append one event; rotate first when the file would exceed the cap.""" line = event.to_json() needs_header = not self._current.exists() if self._current.exists() and ( self._current.stat().st_size + len(line) + 2 > self._max_bytes ): rotate_file(self._current, self._backups) needs_header = True with self._current.open("a", encoding="utf-8") as stream: if needs_header: header = { "header": True, "format": "opcda-diagnozer events v1", "config_sha256": self._digest, } stream.write(json.dumps(header) + "\n") stream.write(line + "\n") def close(self) -> None: """Lifecycle symmetry with the other reporters (nothing to release)."""