🎉 Initial commit
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
"""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
|
||||
|
||||
|
||||
@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")
|
||||
Reference in New Issue
Block a user