Files
opcda-diagnozer/src/opcda_diagnozer/report/jsonl.py
T
2026-10-08 07:57:21 +02:00

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)."""