Request context¶
Every entry records who made the change, from where and how. That information lives in an AuditContext:
| Field | Meaning |
|---|---|
actor_type |
kind of actor, defined by you: "user", "system", "api_key", ...; defaults to "anonymous" |
actor_id |
opaque id of the actor (see privacy) |
actor_label |
human-readable snapshot, e.g. an e-mail address; survives deletion of the user |
remote_addr, user_agent, method, path |
where the request came from |
channel |
entry point: "api", "admin", "cli", "worker", ... |
auth_method |
how the actor authenticated |
request_id |
id of the request |
correlation_id |
links several database transactions of one operation; defaults to request_id |
scope_id |
scope of the change, e.g. a tenant; used when the model has no scope option |
extra |
a dict of anything else, stored as meta; must be JSON-encodable |
Activating a context¶
audit.context(...) activates a context for a block of code, sync or async. It takes either the fields as keyword arguments or a ready AuditContext:
with audit.context(
actor_type="system", actor_label="nightly-cleanup", channel="worker"
):
run_cleanup()
async with audit.context(AuditContext(actor_type="system", channel="worker")):
await run_cleanup()
A nested block replaces the outer context for its duration; it does not inherit the outer fields. Outside any block, entries are written with an anonymous context.
Setting the actor later¶
The actor is usually known only after authentication, when the context is already active. audit.set_actor() updates the active context in place:
with audit.context(channel="api", remote_addr=client_ip):
user = authenticate(request)
audit.set_actor(Actor(type="user", id=str(user.id), label=user.email))
handle(request)
Because the object is mutated rather than replaced, an actor set from a worker thread (for example a synchronous FastAPI dependency) is visible to the code that started it. set_actor() raises RuntimeError when no context() block is active.
The audit_transaction row of a database transaction is created by its first audit entry, with the context of that moment. Each entry also stores a snapshot of the context in data["context"] and its own actor_id, so an actor set after the first entry of a transaction is still recorded on the later entries.
Binding a context to a session¶
Code without context variables (scripts, some task queues, tests) can attach a context to one session instead:
with SessionLocal() as session:
audit.bind(session, AuditContext(actor_type="system", channel="import"))
import_customers(session)
session.commit()
A bound context takes precedence over everything else for that session, and set_actor() does not reach it: change its fields directly. Binding does not turn auditing on; the session still has to come from an installed factory.
Providers¶
AuditTrail(context_provider=...) takes a callable that returns the current AuditContext, or None to fall through. It is for applications that already keep request state somewhere else.
The context of an entry is resolved in this order:
- a context bound to the session with
bind(); context_provider(), when it returns a context;- the context activated with
audit.context(...); - an anonymous context.
What is stored¶
The audit_transaction row holds all fields except scope_id. Each entry's data["context"] holds the snapshot at the time of the entry, leaving out empty fields and actor_id, which has a column of its own:
{"actor_type": "user", "actor_label": "ada@example.com", "remote_addr": "203.0.113.9",
"method": "PATCH", "path": "/notes/1", "channel": "api", "request_id": "5d0c4c8e-...", "meta": {"ticket": "OPS-12"}}
The snapshot keeps each entry's context for as long as the entry itself is kept, even after the audit_transaction row has been dropped by a shorter retention (see partitions).