How Audit Works
UseBetter Audit captures every INSERT, UPDATE, and DELETE transparently — without
you adding captureLog() calls to each route or service. This page walks through
exactly what happens on every mutation so you understand what is recorded, how actor
identity is tracked, what enrichment layers on, and what guarantees you get.
If you just want to get started, skip to Quick Start. Come back here when you want to understand what happens beneath the surface.
The problem with manual audit logging
Section titled “The problem with manual audit logging”The most common approach to audit logging is to call a logging function manually after each mutation:
await db.update(usersTable).set({ name }).where(eq(usersTable.id, id));await audit.log({ table: "users", operation: "UPDATE", actorId: req.user.id });It works, but it has the same flaw as WHERE-clause multi-tenancy: a single missed call leaves a gap in your audit trail. The more mutations your application has, the more likely someone will forget one — especially across teams, across services, and over time.
UseBetter Audit moves capture out of your application code and into the ORM layer. Every mutation goes through a proxy or extension that intercepts it automatically. Even if a route handler does not call anything explicitly, the audit entry is written.
What happens on every mutation
Section titled “What happens on every mutation”Three layers collaborate on every INSERT, UPDATE, or DELETE:
- ORM proxy / extension — wraps your Drizzle or Prisma client and intercepts every write before it reaches the database.
- AsyncLocalStorage — carries the actor identity (set by framework middleware
at the top of the request) through every
awaitboundary without parameter passing. - Adapter write — the adapter builds a structured
audit_logrow — including enrichment rules — and writes it to your database.
Try it yourself
Section titled “Try it yourself”Run mutations, switch actors, enable enrichment rules, and watch what lands in
audit_logs. The log history at the bottom accumulates entries across runs —
the same view you get when you query audit.query() in your application.
| id | name | |
|---|---|---|
| 1 | Alice | [email protected] |
| 2 | Bob | [email protected] |
| 3 | Carol | [email protected] |
Step by step
Section titled “Step by step”1. ORM proxy intercepts the mutation
Section titled “1. ORM proxy intercepts the mutation”When you wrap your ORM client with withAuditProxy (Drizzle) or
withAuditExtension (Prisma), every subsequent mutation goes through a proxy
layer before it reaches the database driver.
Drizzle uses a JavaScript Proxy object to intercept db.insert(),
db.update(), and db.delete() calls. The proxy executes the original query
and, if the table is in auditTables, immediately runs capture with the result.
Prisma uses a Prisma Client Extension ($extends) to add beforeQuery and
afterQuery hooks on write operations. Same result, different mechanism — the
adapter abstracts this away.
Neither approach requires you to modify your route handlers. Wrap once at setup time; capture happens automatically from that point forward.
2. Actor pulled from AsyncLocalStorage
Section titled “2. Actor pulled from AsyncLocalStorage”Node.js AsyncLocalStorage propagates values through async call chains without
explicit parameter passing. The framework middleware sets up a context scope at
the very beginning of each request:
Request arrives └─ betterAuditHono() middleware └─ Extracts actorId from Authorization: Bearer <jwt> └─ Creates AuditContext { actorId: "user-42" } └─ Stores context in AsyncLocalStorage └─ Route handler runs └─ auditedDb.insert(usersTable).values(body) └─ Proxy intercepts └─ AsyncLocalStorage.getStore() → { actorId: "user-42" } └─ captureLog({ actorId: "user-42", ... })The context is scoped to the request. When the request ends, the scope is cleaned up. Concurrent requests each have their own scope — context never leaks between them.
3. Before/after snapshot captured
Section titled “3. Before/after snapshot captured”For INSERT, the proxy reads the new row from the query result — this is
afterData. beforeData is null.
For DELETE, the proxy issues a SELECT before the delete executes to capture
the current state of the row. This becomes beforeData; afterData is null.
For UPDATE, the proxy reads both the previous state (pre-query SELECT) and the new state (post-query result or re-fetch). Both appear in the entry so reviewers can see exactly what changed, field by field.
The snapshot is always the full row — not just the changed columns. Every entry is self-contained: it tells the complete story of the record at that point in time.
4. Enrichment rules applied
Section titled “4. Enrichment rules applied”Before the entry is written, the adapter checks the enrichment registry. Rules
registered with audit.enrich() are matched by table name and operation:
audit.enrich("users", "INSERT", { label: "New user registered", severity: "low", compliance: ["soc2"],});
audit.enrich("users", "DELETE", { label: "User account deleted", severity: "critical", compliance: ["gdpr", "soc2"], redact: ["email", "phone"],});Enrichment fields are merged into the entry: label, severity, compliance,
and any redacted fields are removed from beforeData / afterData before storage.
Enrichment is declarative and registered once at startup. Your route handlers do not know it is happening.
5. Audit entry written to audit_logs
Section titled “5. Audit entry written to audit_logs”After enrichment, the adapter inserts a row into audit_logs:
{ id: string; // UUID timestamp: Date; // server clock at capture time tableName: string; // e.g. "users" operation: "INSERT" | "UPDATE" | "DELETE"; recordId: string; // primary key of the mutated row actorId: string | null; // from AsyncLocalStorage, or null if extraction failed beforeData: Record<string, unknown> | null; // redacted fields removed afterData: Record<string, unknown> | null; // redacted fields removed label: string | undefined; severity: "low" | "medium" | "high" | "critical" | undefined; compliance: string[] | undefined;}You can query this table directly or use audit.query():
const result = await audit.query() .resource("users") .actor("user-42") .since("24h") .list();Why you can trust this
Section titled “Why you can trust this”A missing captureLog() call cannot create a gap
Section titled “A missing captureLog() call cannot create a gap”Capture is delegated to the proxy layer, not your application code. There is no
captureLog() to forget. Every write that goes through auditedDb is captured.
Request isolation is a Node.js guarantee
Section titled “Request isolation is a Node.js guarantee”AsyncLocalStorage is a Node.js built-in. Its isolation guarantee is the same one
that makes session stores and request-scoped loggers safe under high concurrency.
One request’s actorId is never visible to another request.
Fail-open does not mean silent failure
Section titled “Fail-open does not mean silent failure”If actor extraction fails, the request proceeds and the entry is still written —
with actorId: null. This is an explicit signal that attribution was unavailable,
not that capture was skipped. To fail-closed, configure onError on the middleware.
Enrichment is append-only, not a filter
Section titled “Enrichment is append-only, not a filter”Enrichment rules add fields to the stored entry; they never suppress or delay the
write. The redact option removes sensitive field values from beforeData /
afterData, but the row itself is always written. You cannot accidentally configure
enrichment in a way that drops log entries.
Summary
Section titled “Summary”| What | How | Why it is reliable |
|---|---|---|
| Automatic capture | ORM proxy / Prisma extension intercepts all writes | No captureLog() to forget |
| Actor attribution | AsyncLocalStorage propagates actor from middleware | No parameter passing; concurrent requests never share context |
| Before/after snapshots | Pre-query SELECT (UPDATE/DELETE) + post-query result (INSERT/UPDATE) | Full row state at each point in time |
| Fail-open | Missing actor → actorId: null, request still proceeds | Audit trail has no gaps |
| Enrichment | Declarative rules registered once at startup | Route handlers never need to know |
| Storage | Your own database | No external service; query with your existing tooling |
Next steps
Section titled “Next steps”- Actor Context — extractors,
mergeAuditContext(), and background job contexts - Enrichment — labels, severity, compliance tags, and field redaction in detail
- Adapters — ORM adapter reference and error handling
- Quick Start — working example with ORM + framework middleware in one page