wake-alarm/wake_alarm/tests/test_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

111 lines
3.5 KiB
Python

"""Tests for the JSONL log sink."""
from __future__ import annotations
import json
import logging
import sys
from typing import TYPE_CHECKING
from wake_alarm._log_sink import BACKUP_COUNT, MAX_BYTES, JsonlFormatter, build_handler
if TYPE_CHECKING:
from pathlib import Path
def _record(msg: str = "hello", *, level: int = logging.INFO) -> logging.LogRecord:
"""Build a plain log record."""
return logging.LogRecord("wake_alarm.t", level, __file__, 7, msg, None, None)
def test_format_emits_single_line_json() -> None:
"""One record renders as exactly one parseable JSON line."""
rendered = JsonlFormatter().format(_record())
assert "\n" not in rendered
assert json.loads(rendered)["msg"] == "hello"
def test_format_includes_core_fields() -> None:
"""Level, logger and timestamp are always present."""
payload = json.loads(JsonlFormatter().format(_record(level=logging.WARNING)))
assert payload["level"] == "WARNING"
assert payload["logger"] == "wake_alarm.t"
assert payload["ts"]
def test_format_interpolates_arguments() -> None:
"""``%s`` style arguments are rendered, not left raw."""
record = logging.LogRecord("t", logging.INFO, __file__, 1, "hi %s", ("bob",), None)
assert json.loads(JsonlFormatter().format(record))["msg"] == "hi bob"
def test_format_merges_extra_fields() -> None:
"""Caller supplied ``extra=`` keys become top-level payload fields."""
record = _record()
record.corr = "wake-20260826-0700"
assert json.loads(JsonlFormatter().format(record))["corr"] == "wake-20260826-0700"
def test_format_skips_private_attributes() -> None:
"""Underscore-prefixed attributes stay out of the payload."""
record = _record()
record._hidden = "no"
assert "_hidden" not in json.loads(JsonlFormatter().format(record))
def test_format_renders_unserialisable_values() -> None:
"""A value JSON cannot encode falls back to repr instead of raising."""
record = _record()
record.thing = object()
assert "object" in json.loads(JsonlFormatter().format(record))["thing"]
def test_format_includes_exception_text() -> None:
"""An exc_info record carries the traceback under ``exc``."""
def _raise() -> None:
msg = "boom"
raise ValueError(msg)
try:
_raise()
except ValueError:
record = logging.LogRecord(
"t",
logging.ERROR,
__file__,
1,
"failed",
None,
sys.exc_info(),
)
assert "ValueError" in json.loads(JsonlFormatter().format(record))["exc"]
def test_build_handler_creates_parent_directory(tmp_path: Path) -> None:
"""The state directory is created on demand."""
target = tmp_path / "deep" / "nested" / "events.jsonl"
build_handler(target).close()
assert target.parent.is_dir()
def test_build_handler_is_configured_for_rotation(tmp_path: Path) -> None:
"""Rotation bounds are applied so the log cannot grow without limit."""
handler = build_handler(tmp_path / "e.jsonl")
try:
assert handler.maxBytes == MAX_BYTES
assert handler.backupCount == BACKUP_COUNT
finally:
handler.close()
def test_handler_writes_a_parseable_line(tmp_path: Path) -> None:
"""End to end: emitting through the handler yields valid JSONL."""
target = tmp_path / "e.jsonl"
handler = build_handler(target)
try:
handler.emit(_record("written"))
finally:
handler.close()
assert json.loads(target.read_text(encoding="utf-8").strip())["msg"] == "written"