Skip to main content
Version: v1.11.0

Audit Log

Quark can record an audit trail of every Create, Update, and Delete into a quark_audit table. Turn it on with Client.EnableAuditLog.

From then on, each write inserts an audit row on the same connection and transaction as the write itself, so the trail is atomic with the data.

err := client.EnableAuditLog(ctx, quark.AuditConfig{
UserFromContext: func(ctx context.Context) string { return userID(ctx) },
TenantFromContext: func(ctx context.Context) string { return tenantID(ctx) },
})

// From here, writes record their own trail:
quark.For[Order](ctx, client).Create(&order) // → one "created" audit row
order.Status = "shipped"
quark.For[Order](ctx, client).Update(&order) // → one "updated" audit row

What gets recorded

EnableAuditLog migrates the quark_audit table (idempotent). Its columns:

ColumnMeaning
idAudit row PK.
tsUTC timestamp of the write.
tenant_id / user_idFrom AuditConfig.*FromContext (empty if unset).
table_nameThe audited table.
operationcreated / updated / deleted.
pkThe affected row's primary key (composite PKs joined with :).
diffJSON change payload (below).

The table is created from a model, so its DDL is portable across all six engines. diff lands in the engine's JSON column, or in text where the engine has none — you never hand-write JSONB or BIGSERIAL DDL yourself.

The diff payload

Operationdiff shape
created / deletedThe full row: {"id": 1, "name": "foo", "qty": 3}.
updated via Update / UpdateFieldsThe new values only: {"status": "shipped"} (no prior value — there's no snapshot).
updated via Tracked.SaveA per-column delta: {"status": {"old": "pending", "new": "shipped"}}.

To get the {old, new} delta, load through dirty tracking so Quark has the snapshot:

tracked, _ := quark.For[Order](ctx, client).Track().Find(id)
tracked.Entity.Status = "shipped"
tracked.Save(ctx) // diff = {"status": {"old": "pending", "new": "shipped"}}

Reading the trail

quark_audit is an ordinary table — map it with a model and query it like anything else:

type AuditEntry struct {
ID int64 `db:"id" pk:"true"`
TS time.Time `db:"ts"`
UserID string `db:"user_id"`
Operation string `db:"operation"`
Table string `db:"table_name"`
PK string `db:"pk"`
}

func (AuditEntry) TableName() string { return "quark_audit" }

// "Who changed this order, and when?"
history, _ := quark.For[AuditEntry](ctx, client).
Where("table_name", "=", "orders").
Where("pk", "=", "42").
OrderBy("ts", "DESC").
List()

fmt.Println(len(history), history[0].Operation)
// => 2 updated

Add a diff quark.JSON[map[string]any] field with db:"diff" when you need the payload too.

Atomicity — written with the commit, not after

The audit row is inserted inline on the CRUD connection:

  • Inside client.Tx, the audit INSERT joins that transaction — data and trail commit together, or both roll back. You never get committed data without its trail, nor a trail for work that was undone.
  • Outside a transaction, the audit INSERT is a separate statement right after the write, with a small crash window between them — wrap writes in client.Tx when you need the guarantee.

That is a deliberately stronger guarantee than the Event Bus, whose post-commit emit can be lost on a crash. Losing an event is tolerable; losing an audit record is not.

Choosing which tables to audit

client.EnableAuditLog(ctx, quark.AuditConfig{
IncludeTables: []string{"orders", "payments"}, // only these
// or:
ExcludeTables: []string{"sessions"}, // everything but these
})

ExcludeTables wins over IncludeTables, and quark_audit itself is always excluded (auditing the audit log would recurse).

Limitations

  • Bulk and WHERE-based methods aren't audited (CreateBatch, UpdateBatch, DeleteBatch, DeleteBy, UpdateMap) — no per-row entity to diff. Loop the single-row methods if you need a trail.
  • Plain Update records new values only; the {old, new} delta needs Tracked.Save.
  • No automatic retentionquark_audit grows unbounded; schedule your own pruning.
  • The audit INSERT bypasses the observer/middleware chain (so it never recurses and never shows up in slow-query logs); identifiers are fixed, values are bound — no injection surface.
JSON numbers

Numbers in diff read back as float64 from a JSON column into a map[string]any — compare accordingly, or decode into a typed struct.