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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user