77 lines
3.0 KiB
Python
77 lines
3.0 KiB
Python
"""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).""" |