feat: eventlog writes to dedicated events journal; init/backfill/close wiring

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 15:03:48 -04:00
co-authored by Claude Fable 5
parent 16c2922bea
commit a3f303e6ba
4 changed files with 82 additions and 3 deletions
+15
View File
@@ -406,6 +406,21 @@ Potential improvements for future versions:
- Journal file encryption - Journal file encryption
- Signed journal entries - 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 ## See Also
- [Configuration Guide](../hbd/config.py) - Full configuration options - [Configuration Guide](../hbd/config.py) - Full configuration options
+13
View File
@@ -165,6 +165,13 @@ async def _run_async(config, config_path=None):
msg_journal = journal_mod.get_journal(config) msg_journal = journal_mod.get_journal(config)
await msg_journal.initialize() 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 # Initialize threshold checker
threshold_checker = threshold_mod.ThresholdChecker( threshold_checker = threshold_mod.ThresholdChecker(
config=config, config=config,
@@ -379,6 +386,12 @@ async def _run_async(config, config_path=None):
except Exception as e: except Exception as e:
logger.warning("Error closing message journal: %s", 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 # Signal DNS worker to exit and await it
try: try:
if "dns_task" in locals() and dns_task: if "dns_task" in locals() and dns_task:
+19 -1
View File
@@ -33,6 +33,7 @@ msg_to_websockets = ws_mod.broadcast
# Module-level state set via setup() # Module-level state set via setup()
_config: dict = {} _config: dict = {}
_loop: Optional[asyncio.AbstractEventLoop] = None
# Tracks which channels fired a WARNING/CRITICAL per host. # Tracks which channels fired a WARNING/CRITICAL per host.
# {host_name: set of channel_names} — used to route RECOVER to the same channels. # {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): def setup(cfg: dict, loop: Optional[asyncio.AbstractEventLoop] = None):
"""Initialize notifier from configuration dict.""" """Initialize notifier from configuration dict."""
global _config global _config, _loop
_config = dict(cfg) _config = dict(cfg)
if loop is not None:
_loop = loop
def reload_config(cfg: dict): def reload_config(cfg: dict):
@@ -131,6 +134,21 @@ def eventlog(host, lvl, m, service=None):
except Exception as e: except Exception as e:
logger.warning("failed to write to logfile: %s", e) logger.warning("failed to write to logfile: %s", e)
msg_to_websockets("message", msg) 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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+33
View File
@@ -218,3 +218,36 @@ def test_filter_events_over_in_memory_ring():
events, more = journal.filter_events(reversed(ring), limit=2) events, more = journal.filter_events(reversed(ring), limit=2)
assert [e["ts"] for e in events] == [3.0, 2.0] assert [e["ts"] for e in events] == [3.0, 2.0]
assert more is True 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