7.2 KiB
7.2 KiB
Phase 2: Recorder, actor context, retention, lifecycle
Status: ⬜ Not Started Parent plan: PLAN.md Domain: backend
Objective
Build the runtime layer over the Phase 1 repository: a thread-safe ActivityRecorder facade
that persists an entry AND pushes a live activity_logged event; an actor ContextVar
populated by the auth layer; a background ActivityLogRetentionEngine mirroring
AutoBackupEngine; and the main.py/dependencies.py wiring (init, DI getter, retention
start/stop, shutdown ordering). After this phase the audit log records nothing yet (no call
sites) — that is Phase 3 — but the full machinery is live and unit-tested.
Tasks
- Create
server/src/ledgrab/core/activity_log/__init__.pyandserver/src/ledgrab/core/activity_log/recorder.py:ActivityRecorder(repo: ActivityLogRepository, processor_manager, *, loop=None).record(category, action, *, severity="info", actor=None, entity_type=None, entity_id=None, entity_name=None, message, metadata=None) -> None:- resolve
actorfrom the actorContextVarwhen not supplied, default"system"; - build an
ActivityLogEntry(idal_<uuid8>,ts=datetime.now(timezone.utc)); - thread-safe write: if called on the event loop thread, write inline via
repo.record(entry)then fire the live event; if called from another thread (zeroconf discovery), marshal the whole write+emit onto the loop vialoop.call_soon_threadsafe(...). Capture the loop lazily (mirrorutils/log_broadcaster.py:ensure_loop/call_soon_threadsafe). Never raise into the caller — audit recording is best-effort and must not break the audited action; log failures atwarning. - live push:
processor_manager.fire_event({"type": "activity_logged", "entry": entry_as_dict}).
- resolve
- Provide a tiny helper to serialize an entry to the same dict shape the API returns (reuse in Phase 4 / frontend).
enabledflag honored: when retention settings sayenabled=false,record()is a no-op — EXCEPT the "audit log disabled" event itself, which must be recorded before the flag takes effect (see retention engine).
- Actor
ContextVar:- Add
current_actor: ContextVar[str](module-level, e.g. incore/activity_log/context.pyorapi/auth.py). Inverify_api_key(api/auth.py), set it next to the existingrequest.state.auth_label = ...(both the authenticated label and the"anonymous"branch). Default"system"when unset. Ensure no cross-request leakage (set on every auth evaluation).
- Add
- Create
server/src/ledgrab/core/activity_log/retention.py:ActivityLogRetentionEngine(repo, db, recorder)mirroringcore/backup/auto_backup.py:_load_settings()/_save_settings()viadb.get_setting("activity_log")/db.set_setting("activity_log", {...}),DEFAULT_SETTINGS = {"enabled": True, "max_days": 90, "max_entries": 20000}.async start()→ spawn_retention_loop()(asyncio.create_task); loop sleeps a sane interval (e.g. hourly) then callsrepo.prune(before_ts=now-max_days, max_entries=...).async stop()→ cancel + await task.get_settings()/async update_settings(...)that persist and apply (changingenabledis logged via the recorder BEFORE disabling).
- Wiring:
main.py: instantiateactivity_log_repo = ActivityLogRepository(db)(module level near other stores); inlifespanstartup buildactivity_recorder+activity_log_retention_engine, pass toinit_dependencies(...), andawait activity_log_retention_engine.start().- In
lifespanshutdown: record asystem/server_shutting_downevent via the recorder as the first shutdown action (before engines/db close), thenawait _bounded("activity_log_retention.stop", activity_log_retention_engine.stop(), timeout=0.5). api/dependencies.py: addactivity_recorder+activity_log_repo+activity_log_retention_engineto_deps, parameters toinit_dependencies, and gettersget_activity_recorder(),get_activity_log_repo(),get_activity_log_retention_engine().
- Realtime allowlist (order matters — do allowlist FIRST so the parity test stays green):
- Add
'activity_logged'to_ALLOWED_SERVER_EVENT_TYPESinserver/src/ledgrab/static/js/core/events-ws.ts(+ a one-line comment naming the source). - Confirm
tests/test_events_ws_parity.pypasses with the new emit type.
- Add
- Unit tests
server/tests/core/test_activity_recorder.py+test_activity_log_retention.py:- recorder persists an entry AND calls
fire_eventwithtype=="activity_logged"; - actor resolves from ContextVar; defaults to
"system"; failure in repo doesn't raise; - cross-thread
record()(call from athreading.Thread) routes through the loop and persists; - retention prunes per settings; settings round-trip via db; disabling logs the disable event.
- recorder persists an entry AND calls
Files to Modify/Create
server/src/ledgrab/core/activity_log/__init__.py— newserver/src/ledgrab/core/activity_log/recorder.py— newserver/src/ledgrab/core/activity_log/context.py— new (actor ContextVar) (or place in auth.py)server/src/ledgrab/core/activity_log/retention.py— newserver/src/ledgrab/api/auth.py— modify: set actor ContextVar inverify_api_keyserver/src/ledgrab/main.py— modify: instantiate, wire lifespan start/shutdownserver/src/ledgrab/api/dependencies.py— modify:_deps,init_dependencies, gettersserver/src/ledgrab/static/js/core/events-ws.ts— modify: allowlistactivity_loggedserver/tests/core/test_activity_recorder.py— newserver/tests/core/test_activity_log_retention.py— new
Acceptance Criteria
- Recorder persists + fires
activity_logged; never raises into callers; thread-safe from non-loop threads. - Actor ContextVar populated by auth; default
"system"; no cross-request leakage. - Retention engine starts/stops cleanly in lifespan; prunes by age + count; settings persist.
server_shutting_downis recorded before teardown; no lost-on-graceful-shutdown entries.test_events_ws_parity.pygreen (allowlist updated). Existing tests still green;ruffclean.
Notes
- Reference:
core/backup/auto_backup.py(engine shape, settings persistence,_boundedshutdown inmain.py),utils/log_broadcaster.py(ensure_loop,call_soon_threadsafethread marshaling),core/processing/processor_manager.py:247(fire_event). - Do not add any instrumentation call sites in this phase — only the machinery. Phase 3
adds the
record(...)calls. (Intermediate commit emits nothing; that is fine and green.) - Freeze the
ActivityLogEntrydict shape here — Phase 4 (API response) and Phase 5 (frontendentry) consume it.
Review Checklist
- All tasks completed
- Code follows project conventions (engine/DI patterns)
- No unintended side effects (no call sites yet; lifespan order correct)
- Build passes (ruff + pytest, incl. parity test)
- Tests pass (new + existing)