wake-alarm/wake_alarm/_correlation.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

52 lines
1.7 KiB
Python

"""Derive the identifier that joins one morning's PC and phone log lines.
The id is **derived, never generated**: both halves compute it independently
from the resolved alarm instant, so no handshake, no uuid exchange and no
network round-trip is involved. That matters because the two devices must
still agree when sync is down -- which is exactly when the logs are worth
reading.
Local time, matching ``_trigger``'s local-time basis (``_state.py`` keys by
UTC, which is a separate known mismatch tracked in the hardening plan).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
# ``wake-YYYYMMDD-HHMM``. Stable across processes and across devices.
_CORRELATION_FORMAT = "wake-%Y%m%d-%H%M"
def correlation_id(fire_time: datetime) -> str:
"""Build the correlation id for the alarm firing at *fire_time*.
Args:
fire_time: The resolved local fire instant for today's alarm.
Returns:
A string of the form ``wake-20260826-0700``.
"""
return fire_time.strftime(_CORRELATION_FORMAT)
def correlation_id_for(hour: int, minute: int, *, now: datetime) -> str:
"""Build the correlation id from an ``(hour, minute)`` alarm on *now*'s date.
Convenience for callers that hold the synced alarm rather than a resolved
datetime. The date comes from *now*, so a caller must pass the date the
alarm belongs to, not merely the current instant.
Args:
hour: Alarm hour, 0-23.
minute: Alarm minute, 0-59.
now: Any datetime on the alarm's local date.
Returns:
The correlation id string.
"""
return correlation_id(now.replace(hour=hour, minute=minute))