mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 14:47:50 +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
111 lines
3.5 KiB
Python
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"
|