🚧 WIP
This commit is contained in:
@@ -1,25 +1,77 @@
|
||||
"""Rotating JSONL reporter — the primary evidence trail (IMPLEMENTATION_PLAN §5.1).
|
||||
|
||||
Phase 2 adds: size-based rotation with ``backups`` kept, atomic-ish append,
|
||||
and a header line carrying the config digest for provenance.
|
||||
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
|
||||
|
||||
|
||||
class JsonlReporter:
|
||||
"""One JSON event per line, rotated by size — implemented in Phase 2."""
|
||||
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 __init__(self, directory: str | Path, max_mb: int = 50, backups: int = 10) -> None:
|
||||
|
||||
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_mb = max_mb
|
||||
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:
|
||||
raise NotImplementedError(
|
||||
"Phase 2: JSONL reporter (docs/IMPLEMENTATION_PLAN.md §5.1)"
|
||||
)
|
||||
"""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)."""
|
||||
Reference in New Issue
Block a user