mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Adds the diagnostic substrate the rest of the hardening work builds on. A 6am failure should be reconstructable afterwards instead of re-debugged. Two sinks, deliberately. stderr -> journal is unchanged. Alongside it, one JSON object per line in ~/.local/state/wake_alarm/events.jsonl, so a morning can be replayed with jq. The hard constraint is that _trigger is a fresh process every 60 seconds, so an in-memory rate limiter would reset on every tick and suppress nothing. _log_dedupe therefore persists the last emitted fingerprint to disk and flushes a suppressed_repeats count onto the next distinct record. Verified across separate processes: 4 invocations logging the same routine event produce 1 record plus an accurate count of 3. That is what satisfies the standing "do not raise the level of _sync's degraded-read paths" rule by construction, rather than by keeping levels artificially low and losing the signal. The correlation id is derived, never generated: both halves compute wake-YYYYMMDD-HHMM from the resolved alarm instant, so the PC and the phone agree with no handshake and no uuid exchange -- which matters precisely when sync is down. Logging never raises. An unwritable sink degrades to journal-only. _alarm.py and _sync.py keep the default component= for now; tagging them means splitting them for the 250-line gate, which belongs in the phases that actually restructure those files. 389 tests pass, 100% branch coverage held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
105 lines
2.8 KiB
Python
105 lines
2.8 KiB
Python
"""JSONL log sink: machine-readable events alongside the human journal.
|
|
|
|
The journal (stderr -> systemd) stays exactly as it was; this is *additive*.
|
|
Every record is one JSON object on one line, so a failed wake-up can be
|
|
reconstructed with ``jq`` instead of re-derived by hand:
|
|
|
|
jq 'select(.corr == "wake-20260826-0700")' ~/.local/state/wake_alarm/events.jsonl
|
|
|
|
Extra keyword arguments passed to a logging call via ``extra={"fields": {...}}``
|
|
are merged into the record, so callers add structure without a bespoke API.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from logging.handlers import RotatingFileHandler
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
# Attribute names ``logging`` sets on every record; anything else in __dict__
|
|
# came from the caller's ``extra=`` and belongs in the payload.
|
|
_RESERVED = frozenset(
|
|
{
|
|
"args",
|
|
"asctime",
|
|
"created",
|
|
"exc_info",
|
|
"exc_text",
|
|
"filename",
|
|
"funcName",
|
|
"levelname",
|
|
"levelno",
|
|
"lineno",
|
|
"message",
|
|
"module",
|
|
"msecs",
|
|
"msg",
|
|
"name",
|
|
"pathname",
|
|
"process",
|
|
"processName",
|
|
"relativeCreated",
|
|
"stack_info",
|
|
"taskName",
|
|
"thread",
|
|
"threadName",
|
|
},
|
|
)
|
|
|
|
MAX_BYTES = 5 * 1024 * 1024
|
|
BACKUP_COUNT = 5
|
|
|
|
|
|
class JsonlFormatter(logging.Formatter):
|
|
"""Render a log record as a single-line JSON object."""
|
|
|
|
def format(self, record: logging.LogRecord) -> str:
|
|
"""Serialise *record* as compact JSON.
|
|
|
|
Args:
|
|
record: The record to render.
|
|
|
|
Returns:
|
|
One line of JSON, no trailing newline.
|
|
"""
|
|
payload: dict[str, Any] = {
|
|
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
|
|
"level": record.levelname,
|
|
"logger": record.name,
|
|
"msg": record.getMessage(),
|
|
}
|
|
payload.update(
|
|
{
|
|
key: value
|
|
for key, value in record.__dict__.items()
|
|
if key not in _RESERVED and not key.startswith("_")
|
|
},
|
|
)
|
|
if record.exc_info:
|
|
payload["exc"] = self.formatException(record.exc_info)
|
|
return json.dumps(payload, default=repr, sort_keys=True)
|
|
|
|
|
|
def build_handler(path: Path) -> RotatingFileHandler:
|
|
"""Create the rotating JSONL handler writing to *path*.
|
|
|
|
Args:
|
|
path: Destination file; its parent is created if absent.
|
|
|
|
Returns:
|
|
A configured handler, ready to attach to the root logger.
|
|
"""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
handler = RotatingFileHandler(
|
|
path,
|
|
maxBytes=MAX_BYTES,
|
|
backupCount=BACKUP_COUNT,
|
|
encoding="utf-8",
|
|
)
|
|
handler.setFormatter(JsonlFormatter())
|
|
return handler
|