wake-alarm/wake_alarm/_log_sink.py
Krzysztof kuhy Rudnicki f5cfc3e452 Phase 1: structured JSONL logging with cross-process dedupe
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
2026-08-26 23:00:08 +02:00

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