Skip to content

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.

Dependency direction: framework adapters and ORM proxy depend on core. Core has zero runtime dependencies — all SQL lives in adapter packages.

betterAudit() in core/src/better-audit.ts is the top-level factory. On each call it:

  1. Validates the config — checks retention policy consistency, warns when asyncWrite: true is set without onError.
  2. Creates a Set of audited table names for O(1) membership checks.
  3. Initialises a new EnrichmentRegistry.
  4. Returns a BetterAuditInstance object with all methods closed over the local config.

There is no shared global state — two betterAudit() calls produce fully independent instances.

Every mutation flows through captureLog(). The pipeline executes 10 steps in order:

StepAction
1Early return — skip if tableName not in auditTables; throw if recordId is empty
2Normalize — call normalizeInput() to enforce valid before/after data per operation
3Get context — read the current AuditContext from ALS via getAuditContext()
4Merge — coalesce per-call fields with ALS context fields; per-call values win on conflict
5Assemble — build the AuditLog with a UUID, server timestamp, and all resolved fields
6Diff — for UPDATE only, call computeDiff(before, after) to produce changedFields
7Enrich — resolve the enrichment registry, then call applyEnrichment() (redact → describe → scalars)
8beforeLog hooks — run each hook sequentially; hooks may mutate the log; an error aborts the write
9Write — 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
10afterLog hooks — run each hook sequentially with a Readonly<AuditLog> snapshot

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 catch branch handles the JSON.stringify throw.

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:

OperationbeforeDataafterData
INSERTabsent — field omitted from logkept
DELETEkeptabsent — field omitted from log
UPDATEkeptkept

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.

packages/audit/core/src/context.ts
// Read the current AuditContext, or undefined when outside a request scope
getAuditContext(): AuditContext | undefined
// Run fn inside a new ALS scope with the given context
runWithAuditContext(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 context
mergeAuditContext(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.

Honocontext.req.raw is a standard Web Request. The middleware passes it directly to the shared handleMiddleware() from core:

packages/audit/hono/src/index.ts
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 | undefined header values to plain string.
  • Reconstructs a URL from req.originalUrl ?? req.url and req.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:

packages/audit/express/src/index.ts
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:

  1. Edge middleware (createAuditMiddleware) — extracts the actor and forwards it as the x-better-audit-actor-id request header. The header is always overwritten — set to an empty string when extraction fails — to prevent clients from spoofing the actor id.

  2. Route Handlers (withAuditRoute) — wraps the route handler, extracts the actor from the NextRequest (JWT or forwarded header), and calls runWithAuditContext() for the duration of the handler.

  3. Server Actions (withAudit) — server actions have no Request object. The wrapper reads headers via next/headers, constructs a synthetic new Request("http://localhost", { headers }), runs the extractor against it, then wraps the action in runWithAuditContext(). If headers() 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.

EnrichmentRegistrycore/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.

When registry.resolve(table, operation) is called, it collects configs from four keys in ascending specificity order:

TierKey patternMeaning
1 (lowest)*:*Any table, any operation
2*:OPAny table, specific operation
3table:*Specific table, any operation
4 (highest)table:OPExact 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.

FieldStrategy
label, severity, notify, descriptionLast-write-wins — more specific tier overrides less specific
compliance arrayConcatenate across all tiers, then deduplicate via Set
redact arrayConcatenate across all tiers, then deduplicate via Set
include arrayConcatenate across all tiers, then deduplicate via Set

applyEnrichment(log, resolved) applies the merged enrichment to the log in a fixed order:

  1. Redact — remove listed fields (redact) or remove all unlisted fields (include) from beforeData, afterData, and diff.changedFields. Removed field names are recorded in log.redactedFields (sorted).
  2. 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.
  3. Scalars — apply label, severity, notify only when not already set on the log (enrichment fills gaps; per-call values take precedence). compliance is concatenated and deduplicated with any existing value.

Enrichment never suppresses the write — it only adds or transforms fields.

FieldTypeDescription
labelstringHuman-readable event label
description(ctx: EnrichmentDescriptionContext) => stringDynamic description computed after redaction
severity"low" | "medium" | "high" | "critical"Severity classification
compliancestring[]Compliance framework tags (e.g. ["soc2", "gdpr"])
notifybooleanMark for notification routing
redactstring[]Top-level field names to remove from data snapshots
includestring[]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.

AuditDatabaseAdaptercore/src/types.ts

Section titled “AuditDatabaseAdapter — core/src/types.ts”
packages/audit/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.

MethodRequiredUsed by
writeLogYescaptureLog() pipeline
queryLogsNoaudit.query(), audit.export(), audit.exportResponse()
getLogByIdNoConsole dashboard
getStatsNoConsole dashboard
purgeLogsNoaudit-cli purge, retention scheduler

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.

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 to onError.
  • afterLog — receives Readonly<AuditLog> after the write completes. Mutations have no effect. Errors are caught and routed to onError.

onBeforeLog(hook) and onAfterLog(hook) both return a dispose function to unregister the hook.

drizzleAuditAdapter.getStats() in drizzle/src/adapter.ts runs 6 aggregation queries in parallel via Promise.all():

QueryComputes
1totalLogs (COUNT), tablesAudited (COUNT DISTINCT)
2eventsPerDay grouped by date_trunc('day', timestamp), up to 365 entries
3topActors grouped by actorId, top 10, NULL actors excluded
4topTables grouped by tableName, top 10
5operationBreakdown grouped by operation
6severityBreakdown 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.