89 lines
3.2 KiB
Python
89 lines
3.2 KiB
Python
"""Event model: DiagEvent, Severity, EventKind (SPECIFICATION §8).
|
||
|
||
Every anomaly report is one :class:`DiagEvent`. ``time_utc`` is always the
|
||
**local client receive time** (wall clock), so events stay comparable even when
|
||
the remote host clock is wrong; device timestamps travel inside ``detail``,
|
||
clearly labeled (SPECIFICATION §8).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from dataclasses import asdict, dataclass, field
|
||
from datetime import datetime, timezone
|
||
from enum import Enum
|
||
from typing import Any
|
||
|
||
|
||
def utc_now() -> datetime:
|
||
"""Timezone-aware current UTC time (the local receive clock)."""
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
class Severity(str, Enum):
|
||
INFO = "INFO"
|
||
WARN = "WARN"
|
||
CRITICAL = "CRITICAL"
|
||
|
||
|
||
class EventKind(str, Enum):
|
||
"""Event kinds — one per detection rule D1–D15 (SPECIFICATION §6)."""
|
||
|
||
CONNECTED = "CONNECTED"
|
||
DISCONNECTED = "DISCONNECTED" # D1
|
||
RECONNECTED = "RECONNECTED" # D2
|
||
RECONNECT_FLAPPING = "RECONNECT_FLAPPING" # D3
|
||
SLOW_POLL = "SLOW_POLL" # D4
|
||
SLOW_POLL_STREAK = "SLOW_POLL_STREAK" # D5
|
||
LATENCY_TREND = "LATENCY_TREND" # D6
|
||
POLL_HANG = "POLL_HANG" # D7
|
||
QUALITY_BAD = "QUALITY_BAD" # D8
|
||
QUALITY_UNCERTAIN = "QUALITY_UNCERTAIN" # D9
|
||
QUALITY_BAD_PERSISTENT = "QUALITY_BAD_PERSISTENT" # D10
|
||
QUALITY_FLAPPING = "QUALITY_FLAPPING" # D11
|
||
STALE_TIMESTAMP = "STALE_TIMESTAMP" # D12
|
||
FROZEN_VALUE = "FROZEN_VALUE" # D13
|
||
CONTROL_LINK_DEGRADED = "CONTROL_LINK_DEGRADED" # D14
|
||
HEARTBEAT = "HEARTBEAT" # D15
|
||
DEVICE_LINK_DOWN = "DEVICE_LINK_DOWN" # D16
|
||
DEVICE_LINK_UP = "DEVICE_LINK_UP" # D16 recovery
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class EventContext:
|
||
"""Snapshot counters attached to every event (SPECIFICATION §8)."""
|
||
|
||
uptime_s: float
|
||
reconnects_total: int
|
||
rtt_p95_ms: float | None = None
|
||
tags_bad: int = 0
|
||
tags_total: int = 0
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DiagEvent:
|
||
"""One structured diagnostic record — JSONL-ready via :meth:`to_json`."""
|
||
|
||
time_utc: datetime
|
||
kind: EventKind
|
||
severity: Severity
|
||
detail: dict[str, Any] = field(default_factory=dict)
|
||
context: EventContext | None = None
|
||
|
||
def to_json(self) -> str:
|
||
"""Serialize as one JSON line (SPECIFICATION §8, JSONL append-safe)."""
|
||
if self.time_utc.tzinfo is None:
|
||
raise ValueError("time_utc must be timezone-aware (use events.utc_now())")
|
||
payload: dict[str, Any] = {
|
||
"time_utc": _utc_z(self.time_utc),
|
||
"kind": self.kind.value,
|
||
"severity": self.severity.value,
|
||
"detail": self.detail,
|
||
"context": asdict(self.context) if self.context is not None else None,
|
||
}
|
||
return json.dumps(payload, ensure_ascii=False)
|
||
|
||
|
||
def _utc_z(dt: datetime) -> str:
|
||
"""ISO-8601 UTC with millisecond precision and a Z suffix."""
|
||
return dt.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") |