From a3f303e6ba56419cae68988c4c18fed5a717f681 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Fri, 10 Jul 2026 15:03:48 -0400 Subject: [PATCH] feat: eventlog writes to dedicated events journal; init/backfill/close wiring Co-Authored-By: Claude Fable 5 --- docs/MESSAGE_JOURNAL.md | 15 +++++++++++++++ hbd/server/main.py | 17 +++++++++++++++-- hbd/server/notify.py | 20 +++++++++++++++++++- tests/test_events_journal.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/docs/MESSAGE_JOURNAL.md b/docs/MESSAGE_JOURNAL.md index 8af6261..2453f5b 100644 --- a/docs/MESSAGE_JOURNAL.md +++ b/docs/MESSAGE_JOURNAL.md @@ -406,6 +406,21 @@ Potential improvements for future versions: - Journal file encryption - Signed journal entries +## Events journal + +Alert/connectivity events shown on the `/log` page are written to a second, +dedicated journal (one JSON object per line, same rotation mechanism): + +| Key | Default | Meaning | +|---|---|---| +| `events_journal_file` | `events.journal` | Filename inside `journal_dir` | +| `events_journal_max_size` | 10 MB | Rotation threshold | +| `events_journal_max_backups` | 10 | Rotated files kept | + +`journal_dir` and `journal_enabled` are shared with the message journal. +On startup, if the events journal file is empty, it is seeded once from the +pickled in-memory message ring so history survives the upgrade. + ## See Also - [Configuration Guide](../hbd/config.py) - Full configuration options diff --git a/hbd/server/main.py b/hbd/server/main.py index 95c4183..0398f17 100644 --- a/hbd/server/main.py +++ b/hbd/server/main.py @@ -160,11 +160,18 @@ async def _run_async(config, config_path=None): from . import threshold as threshold_mod notify_mod.setup(config, loop=loop) - + # Initialize message journal msg_journal = journal_mod.get_journal(config) await msg_journal.initialize() - + + # Initialize events journal (human-readable event log for the /log page) + events_journal = journal_mod.get_events_journal(config) + await events_journal.initialize() + if data.msgs: + # One-time seed on upgrade: only writes when the journal file is empty + await events_journal.backfill(data.msgs) + # Initialize threshold checker threshold_checker = threshold_mod.ThresholdChecker( config=config, @@ -379,6 +386,12 @@ async def _run_async(config, config_path=None): except Exception as e: logger.warning("Error closing message journal: %s", e) + # Close events journal + try: + await events_journal.close() + except Exception as e: + logger.warning("Error closing events journal: %s", e) + # Signal DNS worker to exit and await it try: if "dns_task" in locals() and dns_task: diff --git a/hbd/server/notify.py b/hbd/server/notify.py index 4cc6655..98ebfb1 100644 --- a/hbd/server/notify.py +++ b/hbd/server/notify.py @@ -33,6 +33,7 @@ msg_to_websockets = ws_mod.broadcast # Module-level state set via setup() _config: dict = {} +_loop: Optional[asyncio.AbstractEventLoop] = None # Tracks which channels fired a WARNING/CRITICAL per host. # {host_name: set of channel_names} — used to route RECOVER to the same channels. @@ -70,8 +71,10 @@ class Notification: def setup(cfg: dict, loop: Optional[asyncio.AbstractEventLoop] = None): """Initialize notifier from configuration dict.""" - global _config + global _config, _loop _config = dict(cfg) + if loop is not None: + _loop = loop def reload_config(cfg: dict): @@ -131,6 +134,21 @@ def eventlog(host, lvl, m, service=None): except Exception as e: logger.warning("failed to write to logfile: %s", e) msg_to_websockets("message", msg) + _journal_event(msg) + + +def _journal_event(msg: dict): + """Schedule an async write of *msg* to the events journal (no-op without a loop).""" + if _loop is None: + return + from . import journal as journal_mod + ej = journal_mod.get_events_journal() + if not ej.enabled: + return + try: + asyncio.run_coroutine_threadsafe(ej.log_event(msg), _loop) + except Exception as e: + logger.warning("failed to schedule events journal write: %s", e) # --------------------------------------------------------------------------- diff --git a/tests/test_events_journal.py b/tests/test_events_journal.py index 61d9796..444409e 100644 --- a/tests/test_events_journal.py +++ b/tests/test_events_journal.py @@ -218,3 +218,36 @@ def test_filter_events_over_in_memory_ring(): events, more = journal.filter_events(reversed(ring), limit=2) assert [e["ts"] for e in events] == [3.0, 2.0] assert more is True + + +# ---- eventlog wiring -------------------------------------------------------- + + +def test_eventlog_writes_to_events_journal(tmp_path): + from hbd.server import data, notify + + journal._events_journal_instance = None + saved_msgs = data.msgs + data.msgs = [] + try: + ej = journal.get_events_journal({"journal_dir": str(tmp_path)}) + + async def scenario(): + await ej.initialize() + notify.setup({}, loop=asyncio.get_running_loop()) + notify.eventlog("h1", "INFO", "hello world") + await asyncio.sleep(0.05) # let the scheduled journal write run + await ej.close() + + asyncio.run(scenario()) + lines = _read_lines(tmp_path) + assert len(lines) == 1 + ev = json.loads(lines[0]) + assert ev["host"] == "h1" + assert ev["level"] == "INFO" + assert ev["message"] == "hello world" + assert isinstance(ev["ts"], float) + finally: + journal._events_journal_instance = None + data.msgs = saved_msgs + notify._loop = None