Skip to content

Getting started

Install

pip install firm-audit              # or: uv add firm-audit
pip install "firm-audit[postgres]"     # psycopg, for PostgreSQL
pip install "firm-audit[mysql]"        # PyMySQL, for MySQL/MariaDB

See Installation for the full list of extras.

Create an audit log

from firm.audit import AuditLog

audit = AuditLog(database_url="sqlite:///audit.db")

By default AuditLog(...) creates the firm_audit_events table if it's missing (create_schema=True). For production schema management, use the bundled Alembic migration and pass create_schema=False — see Database backends.

Record events

audit.record(
    "invoice.paid",
    subject=invoice,                  # a domain object with `.id`, or ("Invoice", invoice_id)
    actor=current_user,
    data={"amount": 4200, "currency": "USD"},
    correlation_id=request_id,
)

audit.history(action="invoice.paid")  # -> most recent events first

References: the subject and the actor

subject and actor are references, and both accept the same forms — type and id are each optional, so you're never forced to invent an id for a non-entity actor:

You pass Stored as (type, id, name) When
a domain object with .id (ClassName, str(obj.id), None) the common case — a model instance
("Invoice", 42) ("Invoice", "42", None) explicit type + id
"cron" (a bare string) ("cron", None, None) a role/kind with no record: system, cron, a webhook, an LLM
Ref("User", 7, name="alice@example.com") ("User", "7", "alice@example.com") attach a human-readable display name
None (the default) (None, None, None) a system event with no actor/subject
from firm.audit import Ref

audit.record("sync.ran", actor="cron")                        # a role, no id
audit.record("user.login", actor=Ref("User", 7, name=email))  # id + display name
audit.record("system.boot")                                   # no actor at all

The display name is stored so the row stays legible after the referenced record is deleted or renamed — an audit log outlives the data it describes. It's display-only; filters never touch it. A bare string becomes the type (a role you can filter on with history(actor="cron")), so pass a string identity like an email as ("user", email), not bare. For a model whose audit identity isn't .id, define __firm_audit_ref__(self) -> Ref on it and it will be used automatically.

Two more free-form channels ride alongside data: changes for a before/after diff (by convention {field: [before, after]}, e.g. changes={"status": ["pending", "paid"]}) and context for request metadata ({"ip": ..., "request_id": ...}).

The same-transaction guarantee

The reason to reach for firm-audit instead of an external log: when your app and the audit log share a database, pass the connection already inside your transaction, and the audit row commits or rolls back together with the business change — never one without the other.

from firm.audit import record

with engine.begin() as conn:
    mark_invoice_paid(conn, invoice_id)
    record(conn, "invoice.paid", subject=invoice, actor=current_user, data={"amount": 4200})
# both commit together, or both roll back — there is no in-between state

AuditLog.record(..., conn=...) does the same thing through the facade — pass conn to join an existing transaction, or omit it to let AuditLog open and commit its own (a separate, non-atomic write — see Configuration).

Clean up

An AuditLog owns a connection pool (and, optionally, a background retention thread). Close it when you're done, or use it as a context manager:

with AuditLog(database_url="sqlite:///audit.db") as audit:
    audit.record("user.login", actor=user)
# closed automatically

A complete example

from firm.audit import AuditLog

with AuditLog(database_url="sqlite:///audit.db") as audit:
    audit.record("user.login", actor=("User", 7), context={"ip": "127.0.0.1"})
    audit.record(
        "invoice.paid", subject=("Invoice", 42), actor=("User", 7), data={"amount": 4200}
    )

    for event in audit.history(limit=10):
        print(event["created_at"], event["action"])

Next: Retention & querying and Configuration.