"""Single entry point for writing :class:`AuditLog` rows. Every frontend ends up needing the same three-line pattern — open a DB session, append a row, swallow failures so the user-visible request still succeeds. Phase 4.3 inlined that pattern in the admin frontend under a private ``_audit()`` helper; Phase 4.4 lifts it here so the Messages and MCP frontends can call the same function and so the swallow-and-log policy lives in one place. The contract: * ``log(runtime, actor=..., kind=...)`` is fire-and-forget. It awaits the DB write (so callers can ``await`` it before responding and get ordering), but never raises — if the audit insert fails, the function emits an ``exception`` log line and returns. * ``actor`` is a free-form string. By convention: ``"token:"`` for bearer-authenticated traffic, ``"admin:"`` for admin-UI actions, ``"anon"`` for failed-auth paths we still want to record. * ``kind`` is a short tag — see :data:`KNOWN_KINDS` for the set the current frontends emit; new tags don't need a code change here, the column is free-form. * ``**detail`` is JSON-serialised by :func:`append_audit`. Keep it small: paths, methods, status codes — not request bodies. Anything passed here lands in ``AuditLog.detail_json`` verbatim. Why a thin wrapper rather than ``append_audit`` directly: callers want "write if you can, otherwise carry on", and pulling the try/except into every frontend was already starting to drift (admin had it, bearer frontends would have copy-pasted). One module, one policy. """ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any from beaver_gateway.storage import append_audit if TYPE_CHECKING: from beaver_gateway.frontends.base import GatewayRuntime _log = logging.getLogger("beaver_gateway.audit") # Tags currently emitted by the gateway. The set is informational — # ``AuditLog.kind`` is free-form so new code can introduce new tags # without touching this list — but listing them here gives the admin UI # and any downstream log consumers one canonical reference. KNOWN_KINDS: frozenset[str] = frozenset( { "messages", # POST /v1/messages accepted "mcp_call", # /mcp//... proxied "tool_call", # a model's tool call seen by the PreToolUse hook "login_ok", "login_failed", "logout", "token_create", "token_revoke", } ) async def log( runtime: GatewayRuntime, *, actor: str, kind: str, agent_name: str | None = None, **detail: Any, ) -> None: """Best-effort audit insert. Never raises. Opens its own short-lived :class:`AsyncSession` so callers don't have to thread one through. If the DB hiccups (table missing, disk full, connection drop), we log and move on — the audit trail is observability, not a hard precondition for serving the request. """ try: async with runtime.db.session() as session: await append_audit( session, actor=actor, kind=kind, agent_name=agent_name, detail=detail or None, ) except Exception: # noqa: BLE001 _log.exception( "audit write failed: actor=%s kind=%s agent=%s", actor, kind, agent_name ) __all__ = ["KNOWN_KINDS", "log"]