🚧 WIP
This commit is contained in:
@@ -103,11 +103,22 @@ class EventLogConfig:
|
||||
min_severity: str = "WARN" # INFO / WARN / CRITICAL
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpConfig:
|
||||
"""Optional webhook reporter (spec §9): one JSON event per POST."""
|
||||
|
||||
url: str
|
||||
bearer_token: str | None = None
|
||||
timeout_s: float = 5.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportConfig:
|
||||
heartbeat_s: float = 300.0 # D15
|
||||
jsonl: JsonlConfig = field(default_factory=JsonlConfig)
|
||||
textlog: JsonlConfig = field(default_factory=JsonlConfig) # same knobs: dir/max_mb/backups
|
||||
eventlog: EventLogConfig = field(default_factory=EventLogConfig)
|
||||
http: HttpConfig | None = None # omitted = webhook off
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -183,9 +194,14 @@ def _build_section(cls: type[Any], data: Any, where: str) -> Any:
|
||||
def _build_report(data: Any) -> ReportConfig:
|
||||
data = {} if data is None else dict(data)
|
||||
jsonl = _build_section(JsonlConfig, data.pop("jsonl", None), "report.jsonl")
|
||||
textlog = _build_section(JsonlConfig, data.pop("textlog", None), "report.textlog")
|
||||
eventlog = _build_section(EventLogConfig, data.pop("eventlog", None), "report.eventlog")
|
||||
http_raw = data.pop("http", None)
|
||||
http = None
|
||||
if http_raw is not None:
|
||||
http = _build_section(HttpConfig, http_raw, "report.http")
|
||||
rest = _build_section(ReportConfig, data, "report")
|
||||
return replace(rest, jsonl=jsonl, eventlog=eventlog)
|
||||
return replace(rest, jsonl=jsonl, textlog=textlog, eventlog=eventlog, http=http)
|
||||
|
||||
|
||||
def _build_tags(data: Any) -> tuple[TagEntry, ...]:
|
||||
@@ -328,6 +344,20 @@ def _validate(config: Config) -> None:
|
||||
raise ConfigError(f"report.jsonl.max_mb: must be >= 1, got {config.report.jsonl.max_mb}")
|
||||
if config.report.jsonl.backups < 0:
|
||||
raise ConfigError(f"report.jsonl.backups: must be >= 0, got {config.report.jsonl.backups}")
|
||||
if config.report.textlog.max_mb < 1:
|
||||
raise ConfigError(f"report.textlog.max_mb: must be >= 1, got {config.report.textlog.max_mb}")
|
||||
if config.report.textlog.backups < 0:
|
||||
raise ConfigError(f"report.textlog.backups: must be >= 0, got {config.report.textlog.backups}")
|
||||
if config.report.http is not None:
|
||||
if not config.report.http.url.startswith(("http://", "https://")):
|
||||
raise ConfigError(
|
||||
"report.http.url: must start with http:// or https://, got "
|
||||
f"{config.report.http.url!r}"
|
||||
)
|
||||
if config.report.http.timeout_s <= 0:
|
||||
raise ConfigError(
|
||||
f"report.http.timeout_s: must be > 0, got {config.report.http.timeout_s}"
|
||||
)
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> Config:
|
||||
|
||||
@@ -554,10 +554,70 @@ def _cmd_probe(
|
||||
return _cmd_read(config_path, server, host, (), source, as_json=False)
|
||||
|
||||
|
||||
def _build_reporters(config: Config):
|
||||
"""Assemble the reporter fan-out from the config (spec §9).
|
||||
|
||||
Console is always on in the foreground ``run``; JSONL, the text anomaly
|
||||
log and the Windows Event Log follow their config sections; the webhook
|
||||
only when ``report.http`` is configured. Returns ``(fan_out, close)``
|
||||
where ``close`` must be called exactly once on shutdown.
|
||||
"""
|
||||
from opcda_diagnozer.report.base import FanOut
|
||||
from opcda_diagnozer.report.jsonl import JsonlReporter, config_digest
|
||||
from opcda_diagnozer.report.textlog import TextLogReporter
|
||||
|
||||
sinks = []
|
||||
|
||||
def console_reporter(event: DiagEvent) -> None:
|
||||
# Console = the human view (spec §9): clear text, status NAME (0xNN)
|
||||
line = f"{event.time_utc.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} UTC {event.severity.value:<8} {event.kind.value}"
|
||||
extras = " ".join(f"{k}={v}" for k, v in event.detail.items())
|
||||
print(f"{line} {extras}" if extras else line, flush=True)
|
||||
|
||||
sinks.append(console_reporter)
|
||||
sinks.append(
|
||||
JsonlReporter(
|
||||
config.report.jsonl.dir,
|
||||
max_mb=config.report.jsonl.max_mb,
|
||||
backups=config.report.jsonl.backups,
|
||||
digest=config_digest(config),
|
||||
)
|
||||
)
|
||||
sinks.append(
|
||||
TextLogReporter(
|
||||
config.report.textlog.dir,
|
||||
max_mb=config.report.textlog.max_mb,
|
||||
backups=config.report.textlog.backups,
|
||||
)
|
||||
)
|
||||
if sys.platform == "win32":
|
||||
from opcda_diagnozer.report.eventlog import EventLogReporter
|
||||
|
||||
sinks.append(
|
||||
EventLogReporter(
|
||||
source=config.report.eventlog.source,
|
||||
min_severity=config.report.eventlog.min_severity,
|
||||
)
|
||||
)
|
||||
if config.report.http is not None:
|
||||
from opcda_diagnozer.report.http import HttpReporter
|
||||
|
||||
sinks.append(
|
||||
HttpReporter(
|
||||
url=config.report.http.url,
|
||||
bearer_token=config.report.http.bearer_token,
|
||||
timeout_s=config.report.http.timeout_s,
|
||||
)
|
||||
)
|
||||
fan_out = FanOut(tuple(sinks), error=lambda msg: print(f"run: {msg}", file=sys.stderr, flush=True))
|
||||
return fan_out
|
||||
|
||||
|
||||
def _cmd_run(config_path: str, server: str | None, host: str | None) -> int:
|
||||
"""Foreground monitoring loop: poll → detect → print (Ctrl+C stops)."""
|
||||
"""Foreground monitoring loop: poll → detect → report (Ctrl+C stops)."""
|
||||
import signal
|
||||
|
||||
from opcda_diagnozer.events import DiagEvent
|
||||
from opcda_diagnozer.monitor.poller import Poller
|
||||
from opcda_diagnozer.opc.automation import AutomationOpcClient
|
||||
|
||||
@@ -587,13 +647,8 @@ def _cmd_run(config_path: str, server: str | None, host: str | None) -> int:
|
||||
connect_timeout_s=config.opc.connect_timeout_s,
|
||||
)
|
||||
|
||||
def reporter(event) -> None:
|
||||
# Console = the human view (spec §9): clear text, status NAME (0xNN)
|
||||
line = f"{event.time_utc.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} UTC {event.severity.value:<8} {event.kind.value}"
|
||||
extras = " ".join(f"{k}={v}" for k, v in event.detail.items())
|
||||
print(f"{line} {extras}" if extras else line, flush=True)
|
||||
|
||||
poller = Poller(client_factory, config, reporter)
|
||||
reporters = _build_reporters(config)
|
||||
poller = Poller(client_factory, config, reporters.emit)
|
||||
stop_requested = threading.Event()
|
||||
|
||||
def _on_ctrl_c(signum, frame) -> None: # noqa: ANN001, ARG001
|
||||
@@ -604,7 +659,10 @@ def _cmd_run(config_path: str, server: str | None, host: str | None) -> int:
|
||||
print("\nrun: stop requested - finishing current poll ...", file=sys.stderr, flush=True)
|
||||
|
||||
signal.signal(signal.SIGINT, _on_ctrl_c)
|
||||
poller.run_forever()
|
||||
try:
|
||||
poller.run_forever()
|
||||
finally:
|
||||
reporters.close()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Reporter protocol — pluggable event sinks (SPECIFICATION §9)."""
|
||||
"""Reporter protocol + fan-out dispatcher (SPECIFICATION §9, IMPLEMENTATION_PLAN §5.4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
from opcda_diagnozer.events import DiagEvent
|
||||
@@ -10,4 +11,65 @@ from opcda_diagnozer.events import DiagEvent
|
||||
class Reporter(Protocol):
|
||||
"""Anything that can receive events; reporting never blocks the poll loop."""
|
||||
|
||||
def emit(self, event: DiagEvent) -> None: ...
|
||||
def emit(self, event: DiagEvent) -> None: ...
|
||||
|
||||
|
||||
def _emit_via(reporter: Reporter, event: DiagEvent) -> None:
|
||||
"""Deliver to a sink that is either an object with ``emit`` or a plain callable.
|
||||
|
||||
The console sink (``_build_reporters``) is a function; the file/Event
|
||||
Log/webhook sinks are objects. Both are legitimate ``Reporter``
|
||||
implementations — the protocol is "anything that can receive events" —
|
||||
so the fan-out duck-types both shapes instead of forcing every sink
|
||||
into a class. Anything else fails loudly (caught + counted by FanOut).
|
||||
"""
|
||||
if callable(getattr(reporter, "emit", None)):
|
||||
reporter.emit(event) # type: ignore[attr-defined]
|
||||
return
|
||||
if callable(reporter):
|
||||
reporter(event) # type: ignore[call-arg]
|
||||
return
|
||||
raise TypeError(
|
||||
f"reporter {type(reporter).__name__} has neither emit() nor is callable"
|
||||
)
|
||||
|
||||
|
||||
class FanOut:
|
||||
"""Dispatches each event to every configured reporter.
|
||||
|
||||
Accepts sinks as objects with ``emit(event)`` **or** plain functions
|
||||
(found live 2026-10-08: the console function sink raised AttributeError
|
||||
on every event — the fan-out duck-types both shapes now). One reporter
|
||||
failing must never kill the others or the poll loop: each delivery is
|
||||
isolated, failures counted and surfaced via the injected ``error``
|
||||
callback (stderr in the CLI). Sinks own their concurrency safety (HTTP
|
||||
queues internally; file reporters append single small lines).
|
||||
"""
|
||||
|
||||
def __init__(self, reporters: tuple[Reporter, ...], error: Callable[[str], None]) -> None:
|
||||
self._reporters = reporters
|
||||
self._error = error
|
||||
self.failures = 0
|
||||
|
||||
def emit(self, event: DiagEvent) -> None:
|
||||
for reporter in self._reporters:
|
||||
try:
|
||||
_emit_via(reporter, event)
|
||||
except Exception as exc: # noqa: BLE001 — a sink must never kill polling
|
||||
self.failures += 1
|
||||
self._error(
|
||||
f"reporter {type(reporter).__name__} failed "
|
||||
f"({type(exc).__name__}: {exc}); event {event.kind.value} "
|
||||
"kept in the other sinks"
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Best-effort close of every sink (shutdown path only)."""
|
||||
for reporter in self._reporters:
|
||||
close = getattr(reporter, "close", None)
|
||||
if close is None:
|
||||
continue
|
||||
try:
|
||||
close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
@@ -1,22 +1,85 @@
|
||||
"""Windows Event Log reporter, WARN+ only (IMPLEMENTATION_PLAN §5.2).
|
||||
"""Windows Event Log reporter, severity-mapped, WARN+ by default (plan §5.2).
|
||||
|
||||
Uses ``win32evtlog``; the source is registered during ``install``. pywin32 is
|
||||
import-guarded so tests can run on any OS (IMPLEMENTATION_PLAN §9).
|
||||
Uses ``win32evtlog`` via a registered **event source handle**
|
||||
(``RegisterEventSource`` → positional ``ReportEvent`` — the pywin32 function
|
||||
takes positional args only; keyword calls raise ``TypeError``). COM imports
|
||||
are guarded so tests and non-Windows runs import the module freely
|
||||
(IMPLEMENTATION_PLAN §9).
|
||||
|
||||
The source's message file is registered during ``install`` (Phase 3); until
|
||||
then events still land in the Application log under the source name (verified
|
||||
live: Windows delivers them, rendering the message with an empty template).
|
||||
A sink failure is counted, never raised — the poll loop must not stall.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from opcda_diagnozer.events import DiagEvent, Severity
|
||||
|
||||
# Windows Event Log types (win32evtlog EVENTLOG_*_TYPE constants)
|
||||
_LEVELS = {
|
||||
Severity.INFO: 4, # EVENTLOG_INFORMATION_TYPE
|
||||
Severity.WARN: 2, # EVENTLOG_WARNING_TYPE
|
||||
Severity.CRITICAL: 1, # EVENTLOG_ERROR_TYPE
|
||||
}
|
||||
_ORDER = {Severity.INFO: 0, Severity.WARN: 1, Severity.CRITICAL: 2}
|
||||
|
||||
|
||||
class EventLogReporter:
|
||||
"""Windows Event Log sink — implemented in Phase 2."""
|
||||
"""Windows Event Log sink — WARN+ only (spec §9), severity-mapped."""
|
||||
|
||||
def __init__(self, source: str = "OPCDA-Diagnozer", min_severity: str = "WARN") -> None:
|
||||
self._source = source
|
||||
self._min_severity = Severity(min_severity)
|
||||
self._min = Severity(min_severity)
|
||||
self._handle = None
|
||||
self._register_source()
|
||||
|
||||
def _register_source(self) -> None:
|
||||
"""Open the event source handle once (kept for the reporter's lifetime).
|
||||
|
||||
``RegisterEventSource(None, source)`` works without admin rights;
|
||||
unregistered sources still deliver (verified live 2026-10-08).
|
||||
"""
|
||||
try:
|
||||
import win32evtlog
|
||||
|
||||
self._handle = win32evtlog.RegisterEventSource(None, self._source)
|
||||
except Exception: # noqa: BLE001 — non-Windows/tests: emit() is a no-op
|
||||
self._handle = None
|
||||
|
||||
def emit(self, event: DiagEvent) -> None:
|
||||
raise NotImplementedError(
|
||||
"Phase 2: Event Log reporter (docs/IMPLEMENTATION_PLAN.md §5.2)"
|
||||
)
|
||||
"""Report one event; below-minimum severities are filtered out."""
|
||||
if _ORDER[event.severity] < _ORDER[self._min]:
|
||||
return
|
||||
if self._handle is None:
|
||||
self._register_source()
|
||||
if self._handle is None:
|
||||
return
|
||||
message = ", ".join(f"{k}={v}" for k, v in event.detail.items())
|
||||
try:
|
||||
import win32evtlog
|
||||
|
||||
# positional args only (win32evtlog.ReportEvent takes no keywords)
|
||||
win32evtlog.ReportEvent(
|
||||
self._handle,
|
||||
_LEVELS[event.severity],
|
||||
0, # category
|
||||
0, # event ID
|
||||
None, # user SID
|
||||
[f"{event.kind.value}: {message}"],
|
||||
None, # raw data: the JSONL file already carries the full record
|
||||
)
|
||||
except Exception: # noqa: BLE001 — a sink must never kill the poll loop
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the event source handle (Windows API hygiene)."""
|
||||
if self._handle is None:
|
||||
return
|
||||
try:
|
||||
import win32evtlog
|
||||
|
||||
win32evtlog.DeregisterEventSource(self._handle)
|
||||
except Exception: # noqa: BLE001 — shutdown is best-effort
|
||||
pass
|
||||
self._handle = None
|
||||
@@ -1,23 +1,111 @@
|
||||
"""Optional HTTP webhook reporter (IMPLEMENTATION_PLAN §5.3).
|
||||
|
||||
Phase 2: POST one JSON event per request, optional bearer token, timeout plus
|
||||
simple retry/drop on failure — must never block the poll loop (bounded queue).
|
||||
POSTs one JSON event per request with an optional bearer token, timeout +
|
||||
drop-on-failure — **never blocks the poll loop**: ``emit`` only pushes onto a
|
||||
bounded queue and a daemon thread drains it. A full queue drops the event
|
||||
(and counts it): losing an alert is always better than stalling polling on a
|
||||
dead webhook host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
|
||||
from opcda_diagnozer.events import DiagEvent
|
||||
|
||||
_QUEUE_BOUND = 1000 # events; ~17 min of anomalies at a pathological rate
|
||||
|
||||
|
||||
class HttpReporter:
|
||||
"""Webhook sink — implemented in Phase 2."""
|
||||
"""Webhook sink — bounded queue + daemon sender thread (spec §9)."""
|
||||
|
||||
def __init__(self, url: str, bearer_token: str | None = None, timeout_s: float = 5.0) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
bearer_token: str | None = None,
|
||||
timeout_s: float = 5.0,
|
||||
post: Callable[[str, str | None, str, float], None] | None = None,
|
||||
) -> None:
|
||||
self._url = url
|
||||
self._bearer_token = bearer_token
|
||||
self._bearer = bearer_token
|
||||
self._timeout_s = timeout_s
|
||||
self._post = post # injectable for tests; None -> urllib in the thread
|
||||
self._queue: queue.Queue[DiagEvent | None] = queue.Queue(maxsize=_QUEUE_BOUND)
|
||||
self._dropped = 0
|
||||
self._sent = 0
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
if self._post is not None:
|
||||
# direct mode (tests): no thread, emit posts synchronously
|
||||
pass
|
||||
else:
|
||||
self._thread = threading.Thread(target=self._drain, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def emit(self, event: DiagEvent) -> None:
|
||||
raise NotImplementedError(
|
||||
"Phase 2: HTTP webhook reporter (docs/IMPLEMENTATION_PLAN.md §5.3)"
|
||||
)
|
||||
"""Queue the event; drop (counted) when the bound is reached.
|
||||
|
||||
In injected-``post`` mode (tests) the send happens inline but is
|
||||
equally isolated: a failing webhook must never raise into the poll
|
||||
loop — losing one alert beats stalling polling on a dead host.
|
||||
"""
|
||||
if self._post is not None:
|
||||
try:
|
||||
self._post(self._url, self._bearer, event.to_json(), self._timeout_s)
|
||||
self._sent += 1
|
||||
except Exception: # noqa: BLE001 — drop on failure, keep polling
|
||||
self._dropped += 1
|
||||
return
|
||||
try:
|
||||
self._queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
self._dropped += 1
|
||||
|
||||
def _drain(self) -> None:
|
||||
import urllib.request
|
||||
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
event = self._queue.get(timeout=0.25)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if event is None:
|
||||
break
|
||||
request = urllib.request.Request(
|
||||
self._url,
|
||||
data=event.to_json().encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
**({"Authorization": f"Bearer {self._bearer}"} if self._bearer else {}),
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self._timeout_s):
|
||||
pass
|
||||
self._sent += 1
|
||||
except Exception: # noqa: BLE001 — drop on failure, keep polling
|
||||
self._dropped += 1
|
||||
|
||||
@property
|
||||
def dropped(self) -> int:
|
||||
"""Events dropped because the queue was full or the POST failed."""
|
||||
return self._dropped
|
||||
|
||||
@property
|
||||
def sent(self) -> int:
|
||||
return self._sent
|
||||
|
||||
def close(self, timeout_s: float = 2.0) -> None:
|
||||
"""Stop the sender thread; queued events are flushed if the drain is fast."""
|
||||
if self._thread is None:
|
||||
return
|
||||
self._stop.set()
|
||||
try:
|
||||
self._queue.put_nowait(None) # wake the drain immediately
|
||||
except queue.Full:
|
||||
pass
|
||||
self._thread.join(timeout=timeout_s)
|
||||
@@ -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)."""
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Plain-text anomaly log — the human-readable incident narrative (spec §9.1).
|
||||
|
||||
``anomalies-YYYYMMDD.log`` in the same directory as the JSONL files: one line
|
||||
per **detected anomaly** (``is_anomaly`` filter — heartbeats and CONNECTED are
|
||||
lifecycle noise), fixed field order ``timestamp SEVERITY KIND message``,
|
||||
clear-text status with the code in parentheses per spec §7. Recoveries
|
||||
(D2 RECONNECTED, DEVICE_LINK_UP) *are* logged so the file reads as a continuous
|
||||
narrative: what broke, when it came back.
|
||||
|
||||
Every line corresponds to a JSONL record (JSONL = machine source of truth,
|
||||
this = convenience view). Size rotation uses the same scheme as the JSONL
|
||||
reporter, per-day file naming keeps `tail -f` sessions stable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from opcda_diagnozer.events import DiagEvent, EventKind, Severity
|
||||
from opcda_diagnozer.report.jsonl import rotate_file
|
||||
|
||||
_MAX_LINE_BYTES = 1024 * 1024 # safety cap: a corrupted detail dict can't grow the file
|
||||
|
||||
|
||||
def is_anomaly(kind: EventKind) -> bool:
|
||||
"""Kinds shown in the text log (spec §9.1): everything except heartbeat/connected."""
|
||||
return kind not in (EventKind.HEARTBEAT, EventKind.CONNECTED)
|
||||
|
||||
|
||||
def message_for(event: DiagEvent) -> str:
|
||||
"""Human message per event kind — clear text, codes in parentheses (spec §7)."""
|
||||
d = event.detail
|
||||
kind = event.kind
|
||||
if kind is EventKind.DISCONNECTED:
|
||||
text = str(d.get("reason", "connection lost"))
|
||||
if "shutting down" in text:
|
||||
return f"stopping: {text}"
|
||||
return f"connection lost: {text}"
|
||||
if kind is EventKind.RECONNECTED:
|
||||
return f"reconnected after {d.get('outage_s', '?')} s outage"
|
||||
if kind is EventKind.RECONNECT_FLAPPING:
|
||||
return f"{d.get('disconnects_in_window', '?')} disconnects within 10 min"
|
||||
if kind in (EventKind.SLOW_POLL, EventKind.SLOW_POLL_STREAK):
|
||||
streak = f", streak {d['streak']}" if "streak" in d else ""
|
||||
return f"read took {d.get('rtt_ms', '?')} ms (warn {d.get('warn_ms', '?')} ms){streak}"
|
||||
if kind is EventKind.LATENCY_TREND:
|
||||
return f"p95 read latency {d.get('rtt_p95_ms', '?')} ms over the last window"
|
||||
if kind is EventKind.POLL_HANG:
|
||||
if "abandoned" in str(d.get("action", "")):
|
||||
return f"read call hung > {d.get('hard_hang_s', '?')} s; COM thread abandoned and rebuilt"
|
||||
return f"read took {d.get('rtt_ms', '?')} ms (timeout {d.get('timeout_ms', '?')} ms); thread kept"
|
||||
if kind in (EventKind.QUALITY_BAD, EventKind.QUALITY_UNCERTAIN):
|
||||
arrow = f" changed {d.get('previous_quality', '?')} -> {d['quality']}" if "quality" in d else ""
|
||||
return f"tag '{d.get('tag', '?')}'{arrow}"
|
||||
if kind is EventKind.QUALITY_BAD_PERSISTENT:
|
||||
return f"tag '{d.get('tag', '?')}' BAD for {d.get('bad_for_s', '?')} s (threshold {d.get('threshold_s', '?')} s)"
|
||||
if kind is EventKind.QUALITY_FLAPPING:
|
||||
return f"tag '{d.get('tag', '?')}' quality changed {d.get('transitions_in_window', '?')} times in {d.get('window_s', '?')} s"
|
||||
if kind is EventKind.STALE_TIMESTAMP:
|
||||
return (f"tag '{d.get('tag', '?')}' device time {d.get('age_s', '?')} s old "
|
||||
f"(max {d.get('max_age_s', '?')} s) while quality GOOD")
|
||||
if kind is EventKind.FROZEN_VALUE:
|
||||
return f"tag '{d.get('tag', '?')}' value {d.get('value')!r} unchanged for {d.get('unchanged_for_s', '?')} s"
|
||||
if kind is EventKind.CONTROL_LINK_DEGRADED:
|
||||
return f"{d.get('tags_bad', '?')} of {d.get('tags_total', '?')} monitored tags BAD while connection OK"
|
||||
if kind is EventKind.DEVICE_LINK_DOWN:
|
||||
parts = [f"device '{d.get('device', '?')}': link indicator '{d.get('indicator_tag', '?')}'"]
|
||||
if d.get("quality") and "quality" in str(d.get("reason", "")):
|
||||
parts.append(str(d.get("reason")))
|
||||
elif d.get("reason"):
|
||||
parts.append(f"read {d.get('value')!r}: {d['reason']}")
|
||||
else:
|
||||
parts.append("down")
|
||||
return " ".join(parts)
|
||||
if kind is EventKind.DEVICE_LINK_UP:
|
||||
return f"device '{d.get('device', '?')}': link indicator '{d.get('indicator_tag', '?')}' back to normal"
|
||||
# unknown kinds: generic but complete rendering
|
||||
return " ".join(f"{k}={v}" for k, v in d.items()) or str(kind.value)
|
||||
|
||||
|
||||
class TextLogReporter:
|
||||
"""Plain-text anomaly log (spec §9.1) — same rotation scheme as JSONL."""
|
||||
|
||||
def __init__(self, directory: str | Path, max_mb: int = 50, backups: int = 10) -> None:
|
||||
self._directory = Path(directory)
|
||||
self._max_bytes = max_mb * 1024 * 1024
|
||||
self._backups = backups
|
||||
self._directory.mkdir(parents=True, exist_ok=True)
|
||||
self._day: str | None = None
|
||||
self._current: Path | None = None
|
||||
|
||||
def _file_for(self, at: datetime) -> Path:
|
||||
"""Per-day file: anomalies-YYYYMMDD.log (stable for `tail -f`)."""
|
||||
day = at.astimezone(timezone.utc).strftime("%Y%m%d")
|
||||
if self._current is None or day != self._day:
|
||||
self._day = day
|
||||
self._current = self._directory / f"anomalies-{day}.log"
|
||||
return self._current
|
||||
|
||||
def emit(self, event: DiagEvent) -> None:
|
||||
if not is_anomaly(event.kind):
|
||||
return
|
||||
line = self._format(event)
|
||||
path = self._file_for(event.time_utc)
|
||||
if path.exists() and path.stat().st_size + len(line) + 2 > self._max_bytes:
|
||||
rotate_file(path, self._backups)
|
||||
with path.open("a", encoding="utf-8") as stream:
|
||||
stream.write(line + "\n")
|
||||
|
||||
def _format(self, event: DiagEvent) -> str:
|
||||
stamp = event.time_utc.astimezone(timezone.utc)
|
||||
ts = stamp.strftime("%Y-%m-%d %H:%M:%S.") + f"{stamp.microsecond // 1000:03d} UTC"
|
||||
message = message_for(event)
|
||||
if len(message) > _MAX_LINE_BYTES:
|
||||
message = message[:_MAX_LINE_BYTES] # safety: never explode on bad detail
|
||||
return f"{ts} {event.severity.value:<8} {event.kind.value:<20} {message}"
|
||||
|
||||
def close(self) -> None:
|
||||
"""Lifecycle symmetry (file handles are opened per append)."""
|
||||
Reference in New Issue
Block a user