Audit log
WithAuditLog writes an audit row for every Create/Update/Delete on
the registered entities. The row is written inside the same
transaction as the change it describes, so a rollback drops the
audit alongside the change — never partial.
Quickstart
app := framework.NewApp(framework.WithDB(db))app.Entity("posts", framework.EntityConfig{ … })app.Entity("users", framework.EntityConfig{ … })// Register entities first, THEN enable audit:app.WithAuditLog(framework.AuditConfig{ Table: "audit_log", // default; can be omitted Actor: func(ctx context.Context) string { return currentUserID(ctx) }, Entities: []string{"posts", "users"}, // empty = audit everything})
Order matters: WithAuditLog walks the registry and attaches hooks,
so it must be called after Entity registrations. Otherwise the
audit hooks bind to no entities.
Schema
CREATE TABLE audit_log ( id TEXT PRIMARY KEY, entity TEXT NOT NULL, op TEXT NOT NULL, -- 'create' | 'update' | 'delete' record_id TEXT NOT NULL, actor_id TEXT, -- nullable tenant_id TEXT, -- nullable; current tenant at write time created_at TIMESTAMPTZ NOT NULL, -- DATETIME on SQLite diff TEXT -- JSON);
EnsureAuditTable(db, table) creates this table; WithAuditLog calls
it for you and panics on failure (this is initialisation-time work —
loud failure is preferable to silent log loss).
tenant_id is populated from tenant.GetTenantID(ctx) at write time, so
multi-tenant apps can scope the audit trail per tenant instead of mixing
every tenant's rows in one table. It is NULL for writes with no tenant
in context (single-tenant apps, system/async writes). The column is added
idempotently — an audit_log table created by an older binary gets a
nullable tenant_id added on the next EnsureAuditTable, with existing
rows left untouched. See multi-tenant for the
tenant-scoped query pattern.
Configuration
| Field | Effect |
|---|---|
Table | Destination table. Defaults to audit_log. |
Actor | Resolves the actor ID (typically user ID) from context.Context. Empty string = system write. |
Entities | Allowlist of entity names to audit. Empty = every registered entity. |
Row shape
For create / update, diff is the post-write record as JSON:
{"new": {"id": "p1", "title": "First", "status": "published"}}For delete, diff is NULL and record_id holds the deleted ID.
There is no automatic old/new diff — diff.new is the full record
after the write, not a delta. If you need the old value, query the
audit log for the previous row.
Transactional behaviour
Audit hooks resolve the active CRUD transaction via
TxFromContext(ctx). The row is inserted through the same
transaction, so:
- A failed
After*hook later in the chain rolls back the audit row
with the parent write. - Batch operations write all audit rows in the same transaction; a
per-item failure rolls back every audit row in the batch. - Audit writes outside a transaction (rare — async hooks) fall back
to the plain connection pool.
What gets audited
AfterCreate→op = 'create',diff = {"new": <record>}AfterUpdate→op = 'update',diff = {"new": <record>}AfterDelete→op = 'delete',diff = NULL
Before* hooks are not audited; the audit only records committed
changes (modulo transactional behaviour above).
Querying the audit log
There are no built-in HTTP endpoints for audit_log. Either:
- Declare
audit_logas a read-only entity (CRUD: falsefor write,
manually register a custom endpoint for reads). - Query directly from your own admin handler.
SELECT entity, op, record_id, actor_id, created_atFROM audit_logWHERE entity = 'posts'ORDER BY created_at DESCLIMIT 50;
In a multi-tenant app, scope the read to the caller's tenant so one
tenant can't see another's audit trail:
SELECT entity, op, record_id, actor_id, created_atFROM audit_logWHERE tenant_id = $1ORDER BY created_at DESCLIMIT 50;
Common mistakes
- Calling
WithAuditLogbeforeEntity. Hooks register against
whatever is in the registry at call time. Audit nothing if you call
it first. - Filtering
Entitiesby table name. It expects the entity
name (the key passed toapp.Entity), not the SQL table name. - Expecting
diffto contain a before/after pair. It contains
onlynew. Compute deltas client-side if needed. - Relying on the audit row for crash recovery. Same transaction:
if the parent write commits, the audit committed. If it didn't, no
audit either.