Architecture
UseBetter Audit’s automatic capture rests on five subsystems. This reference covers each one in engineering depth: the captureLog() pipeline, the Drizzle ORM proxy, AsyncLocalStorage (ALS) bridging per framework, the enrichment registry’s tier resolution, and the storage adapter contract.
Overview — subsystem map
Section titled “Overview — subsystem map”Dependency direction: framework adapters and ORM proxy depend on core. Core has zero runtime dependencies — all SQL lives in adapter packages.
Auto-capture engine
Section titled “Auto-capture engine”betterAudit() factory
Section titled “betterAudit() factory”betterAudit() in core/src/better-audit.ts is the top-level factory. On each call it:
- Validates the config — checks retention policy consistency, warns when
asyncWrite: trueis set withoutonError. - Creates a
Setof audited table names for O(1) membership checks. - Initialises a new
EnrichmentRegistry. - Returns a
BetterAuditInstanceobject with all methods closed over the local config.
There is no shared global state — two betterAudit() calls produce fully independent instances.
captureLog() pipeline
Section titled “captureLog() pipeline”Every mutation flows through captureLog(). The pipeline executes 10 steps in order:
| Step | Action |
|---|---|
| 1 | Early return — skip if tableName not in auditTables; throw if recordId is empty |
| 2 | Normalize — call normalizeInput() to enforce valid before/after data per operation |
| 3 | Get context — read the current AuditContext from ALS via getAuditContext() |
| 4 | Merge — coalesce per-call fields with ALS context fields; per-call values win on conflict |
| 5 | Assemble — build the AuditLog with a UUID, server timestamp, and all resolved fields |
| 6 | Diff — for UPDATE only, call computeDiff(before, after) to produce changedFields |
| 7 | Enrich — resolve the enrichment registry, then call applyEnrichment() (redact → describe → scalars) |
| 8 | beforeLog hooks — run each hook sequentially; hooks may mutate the log; an error aborts the write |
| 9 | Write — call database.writeLog(log) synchronously, or fire-and-forget when asyncWrite is true. Steps 9 and 10 share writeAndRunAfterHooks(); when asyncWrite is true it runs without await and errors are routed to onError or console.error |
| 10 | afterLog hooks — run each hook sequentially with a Readonly<AuditLog> snapshot |
computeDiff() — core/src/diff.ts
Section titled “computeDiff() — core/src/diff.ts”computeDiff(before, after) compares two row snapshots and returns the names of fields that changed:
- Iterates the union of all keys from both objects.
- Compares values with
JSON.stringify— order-sensitive for objects and arrays. - A field present in only one snapshot counts as changed.
- Non-serializable values (e.g. circular references) are always treated as changed; the
catchbranch handles theJSON.stringifythrow.
The result is { changedFields: string[] } stored as AuditLog.diff. Diff is computed only for UPDATE operations.
normalizeInput() — core/src/normalize.ts
Section titled “normalizeInput() — core/src/normalize.ts”normalizeInput() enforces which data fields are meaningful per operation before the log is assembled:
| Operation | beforeData | afterData |
|---|---|---|
INSERT | absent — field omitted from log | kept |
DELETE | kept | absent — field omitted from log |
UPDATE | kept | kept |
Even if the caller passes both fields, a DELETE entry will never have afterData in the stored log. Fields are omitted (absent from the object), not set to null.
AsyncLocalStorage context flow
Section titled “AsyncLocalStorage context flow”Core primitives — core/src/context.ts
Section titled “Core primitives — core/src/context.ts”// Read the current AuditContext, or undefined when outside a request scopegetAuditContext(): AuditContext | undefined
// Run fn inside a new ALS scope with the given contextrunWithAuditContext(context: AuditContext, fn: () => T): Promise<T>
// Merge additional fields into the current scope (or create one) and run fn// Fields in override take precedence over the existing contextmergeAuditContext(override: Partial<AuditContext>, fn: () => T): Promise<T>A single AsyncLocalStorage<AuditContext> instance is created at module load time. storage.run(context, fn) establishes a scope for the duration of fn; nested calls to runWithAuditContext are safe — each scope is fully isolated from concurrent sibling scopes. mergeAuditContext reads the current store and spreads the override, so it works both inside and outside an existing scope.
Per-framework bridging
Section titled “Per-framework bridging”Hono — context.req.raw is a standard Web Request. The middleware passes it directly to the shared handleMiddleware() from core:
await handleMiddleware(extractor, context.req.raw, next, handlerOptions);Express — Express uses Node.js IncomingMessage, which is not a Web Request. The adapter bridges it with toWebRequest(req):
- Normalises
string | string[] | number | undefinedheader values to plainstring. - Reconstructs a URL from
req.originalUrl ?? req.urlandreq.hostname. - Constructs
new Request(url, { method, headers }).
The ALS scope must stay open until after the route handler finishes. The adapter wraps next() in a Promise that resolves only when response.on('finish') or response.on('close') fires:
const nextWrapper = () => new Promise<void>((resolve) => { response.on("finish", resolve); response.on("close", resolve); next(); });await handleMiddleware(extractor, webRequest, nextWrapper, handlerOptions);Next.js — Next.js middleware runs in the Edge Runtime, a separate execution context from route handlers. ALS context set in middleware does not propagate into route handlers. The adapter uses three integration points:
-
Edge middleware (
createAuditMiddleware) — extracts the actor and forwards it as thex-better-audit-actor-idrequest header. The header is always overwritten — set to an empty string when extraction fails — to prevent clients from spoofing the actor id. -
Route Handlers (
withAuditRoute) — wraps the route handler, extracts the actor from theNextRequest(JWT or forwarded header), and callsrunWithAuditContext()for the duration of the handler. -
Server Actions (
withAudit) — server actions have noRequestobject. The wrapper reads headers vianext/headers, constructs a syntheticnew Request("http://localhost", { headers }), runs the extractor against it, then wraps the action inrunWithAuditContext(). Ifheaders()throws (outside a Next.js request context), the action runs without audit context.
Fail-open guarantee. All framework adapters route extractor errors through safeExtract(), which catches any thrown error and returns undefined. If extraction fails, the request proceeds without an ALS context. The log entry is still written — with actorId absent.
Enrichment registry
Section titled “Enrichment registry”EnrichmentRegistry — core/src/enrichment-registry.ts
Section titled “EnrichmentRegistry — core/src/enrichment-registry.ts”The registry is a Map<string, EnrichmentConfig[]> keyed by "table:OPERATION". Multiple rules may be registered for the same key — they accumulate in registration order.
Four specificity tiers
Section titled “Four specificity tiers”When registry.resolve(table, operation) is called, it collects configs from four keys in ascending specificity order:
| Tier | Key pattern | Meaning |
|---|---|---|
| 1 (lowest) | *:* | Any table, any operation |
| 2 | *:OP | Any table, specific operation |
| 3 | table:* | Specific table, any operation |
| 4 (highest) | table:OP | Exact table + operation match |
All matching configs from all tiers are collected into a flat list and passed to mergeEnrichmentConfigs(). Within each tier, configs are ordered by registration sequence.
Merge strategy
Section titled “Merge strategy”| Field | Strategy |
|---|---|
label, severity, notify, description | Last-write-wins — more specific tier overrides less specific |
compliance array | Concatenate across all tiers, then deduplicate via Set |
redact array | Concatenate across all tiers, then deduplicate via Set |
include array | Concatenate across all tiers, then deduplicate via Set |
Application order — applyEnrichment()
Section titled “Application order — applyEnrichment()”applyEnrichment(log, resolved) applies the merged enrichment to the log in a fixed order:
- Redact — remove listed fields (
redact) or remove all unlisted fields (include) frombeforeData,afterData, anddiff.changedFields. Removed field names are recorded inlog.redactedFields(sorted). - Description — call the
description(ctx)function with structurally-cloned, post-redaction snapshots. The description function sees only the data that will be stored. If the function throws, the description is left unset and the log is written anyway. - Scalars — apply
label,severity,notifyonly when not already set on the log (enrichment fills gaps; per-call values take precedence).complianceis concatenated and deduplicated with any existing value.
Enrichment never suppresses the write — it only adds or transforms fields.
EnrichmentConfig field reference
Section titled “EnrichmentConfig field reference”| Field | Type | Description |
|---|---|---|
label | string | Human-readable event label |
description | (ctx: EnrichmentDescriptionContext) => string | Dynamic description computed after redaction |
severity | "low" | "medium" | "high" | "critical" | Severity classification |
compliance | string[] | Compliance framework tags (e.g. ["soc2", "gdpr"]) |
notify | boolean | Mark for notification routing |
redact | string[] | Top-level field names to remove from data snapshots |
include | string[] | Top-level field names to keep; all others removed |
redact and include match top-level keys only. To redact a nested field like profile.ssn, list the parent key "profile". Redaction does not apply to metadata.
Storage abstraction
Section titled “Storage abstraction”AuditDatabaseAdapter — core/src/types.ts
Section titled “AuditDatabaseAdapter — core/src/types.ts”interface AuditDatabaseAdapter { writeLog(log: AuditLog): Promise<void>; // required queryLogs?(spec: AuditQuerySpec): Promise<AuditQueryResult>; getLogById?(id: string): Promise<AuditLog | null>; getStats?(options?: { since?: Date }): Promise<AuditStats>; purgeLogs?(options: { before: Date; tableName?: string }): Promise<{ deletedCount: number }>;}Only writeLog is required. All other methods are optional — the core engine checks for their presence and throws a descriptive error when a missing method is called.
| Method | Required | Used by |
|---|---|---|
writeLog | Yes | captureLog() pipeline |
queryLogs | No | audit.query(), audit.export(), audit.exportResponse() |
getLogById | No | Console dashboard |
getStats | No | Console dashboard |
purgeLogs | No | audit-cli purge, retention scheduler |
Sync vs async write modes
Section titled “Sync vs async write modes”By default, captureLog() awaits database.writeLog(log) before returning. The mutation does not complete until the audit entry is durably written.
When asyncWrite: true is configured (at the instance level or per-call via CaptureLogInput.asyncWrite), writeLog() is called without await. The mutation returns immediately and the write completes in the background. Errors are caught and routed to onError, or logged to console.error if onError is not set.
beforeLog / afterLog hooks
Section titled “beforeLog / afterLog hooks”Hooks are stored as ordered arrays and run sequentially:
beforeLog— receives the fully assembled, enriched, post-redaction log. May mutate the log (e.g. add a custom field). Errors are caught and routed toonError.afterLog— receivesReadonly<AuditLog>after the write completes. Mutations have no effect. Errors are caught and routed toonError.
onBeforeLog(hook) and onAfterLog(hook) both return a dispose function to unregister the hook.
Stats implementation (Drizzle)
Section titled “Stats implementation (Drizzle)”drizzleAuditAdapter.getStats() in drizzle/src/adapter.ts runs 6 aggregation queries in parallel via Promise.all():
| Query | Computes |
|---|---|
| 1 | totalLogs (COUNT), tablesAudited (COUNT DISTINCT) |
| 2 | eventsPerDay grouped by date_trunc('day', timestamp), up to 365 entries |
| 3 | topActors grouped by actorId, top 10, NULL actors excluded |
| 4 | topTables grouped by tableName, top 10 |
| 5 | operationBreakdown grouped by operation |
| 6 | severityBreakdown grouped by severity, NULL severity excluded |
Results are assembled by assembleStats() from core into the AuditStats shape. All SQL lives in adapter packages — the zero-dependency core never imports a database driver or ORM.