Skip to content

Configuration

All configuration is passed to the betterAudit() factory. Only database and auditTables are required.

src/audit.ts
import { betterAudit } from "@usebetterdev/audit";
import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users", "orders", "payments"],
asyncWrite: false,
maxQueryLimit: 1000,
retention: { days: 365 },
onError: (error) => logger.error("Audit write failed", error),
});
OptionTypeDefaultDescription
databaseAuditDatabaseAdapterRequired. ORM adapter that handles writing and querying audit logs.
auditTablesstring[]Required. SQL table names to audit. Events for unlisted tables are silently skipped.
asyncWritebooleanfalseWhen true, writes are fire-and-forget. Per-call asyncWrite overrides this.
maxQueryLimitnumber1000Hard upper-bound for query().limit(n). Throws if n exceeds this value.
retentionRetentionPolicyRetention policy for automatic purge. See Retention policy.
onError(error: unknown) => voidconsole.errorCalled when an async write or afterLog hook fails.
beforeLogBeforeLogHook[][]Hooks that run before each log is written. See Lifecycle hooks.
afterLogAfterLogHook[][]Hooks that run after each log is written. See Lifecycle hooks.
consoleConsoleRegistrationConsole integration. See Console integration.

The auditTables array is an allowlist of SQL table names that should be audited. Any mutation on a table not in this list is silently ignored — no error, no log entry.

const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users", "orders"],
});
// INSERT into "users" → captured
// INSERT into "orders" → captured
// INSERT into "sessions" → silently skipped

Enrichment adds human-readable labels, severity levels, compliance tags, and dynamic descriptions to audit entries. Register enrichment rules with audit.enrich(). See the Enrichment guide for a full walkthrough with examples.

src/audit.ts
import { betterAudit } from "@usebetterdev/audit";
import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users", "payments"],
});
audit.enrich("users", "DELETE", {
label: "User account deleted",
severity: "critical",
compliance: ["gdpr", "soc2"],
redact: ["password", "ssn"],
});
audit.enrich("payments", "*", {
label: "Payment mutation",
severity: "high",
compliance: ["pci"],
});
OptionTypeDescription
labelstringHuman-readable label for the audit entry.
description(context) => stringDynamic description. Receives { before, after, diff, actorId, metadata }.
severity"low" | "medium" | "high" | "critical"Severity level for the operation.
compliancestring[]Compliance tags (e.g., "gdpr", "soc2", "hipaa", "pci").
notifybooleanFlag for downstream notification integrations.
redactstring[]Field names to remove from beforeData/afterData. Mutually exclusive with include.
includestring[]Field names to keep — all others are removed. Mutually exclusive with redact.

The description function receives context about the mutation:

audit.enrich("users", "UPDATE", {
label: "User profile updated",
severity: "medium",
description: ({ before, after, diff, actorId }) =>
`Actor ${actorId} changed fields: ${diff?.changedFields.join(", ")}`,
});

When multiple enrichment rules match an event, they are resolved from least to most specific. Scalar values (like label) are overwritten by more specific rules. Array values (like compliance) are concatenated and deduplicated.

TierPatternMatches
1 (least specific)"*", "*"All tables, all operations
2"*", "DELETE"All tables, specific operation
3"users", "*"Specific table, all operations
4 (most specific)"users", "DELETE"Specific table, specific operation
// Tier 1: global default
audit.enrich("*", "*", {
severity: "low",
compliance: ["soc2"],
});
// Tier 4: specific override
audit.enrich("users", "DELETE", {
severity: "critical", // overwrites "low"
compliance: ["gdpr"], // merged → ["soc2", "gdpr"]
});

Control which fields appear in beforeData and afterData snapshots. Two modes are available — use one or the other, not both.

Remove specific fields. Everything else is kept:

audit.enrich("users", "*", {
redact: ["password", "ssn", "secret_key"],
});
// beforeData: { id: "1", name: "Alice", password: "[REDACTED]" }
// → stored as: { id: "1", name: "Alice" } (password removed)

Keep only the listed fields. Everything else is removed:

audit.enrich("users", "*", {
include: ["id", "name", "email"],
});
// beforeData: { id: "1", name: "Alice", password: "hash", ssn: "123" }
// → stored as: { id: "1", name: "Alice", email: "[email protected]" }

When fields are redacted, the audit entry’s redactedFields column records which fields were removed — useful for compliance audits that need to prove sensitive data was excluded.

Configure automatic purge of old audit entries by setting a retention window:

const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users", "orders"],
retention: { days: 365 },
});
OptionTypeDefaultDescription
daysnumberRequired. Purge entries older than this many days. Must be a positive integer.
tablesstring[]all tablesWhen set, only purge entries for these specific tables.

See Retention Policies for table-scoped retention, automated purge scheduling, archiving strategies, and legal hold patterns.

Hooks let you observe or transform audit entries at write time. There are two hook points: before the log is written and after.

Pass hook arrays when creating the audit instance. These run for every audit entry:

src/audit.ts
import { betterAudit } from "@usebetterdev/audit";
import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users"],
beforeLog: [
(log) => {
// Mutate the log before it's written
log.metadata = { ...log.metadata, environment: "production" };
},
],
afterLog: [
(log) => {
// Observe the written log (read-only snapshot)
metrics.increment("audit.entries", { table: log.tableName });
},
],
});

Register hooks dynamically after creation. Returns a dispose function to unregister:

// Register
const dispose = audit.onBeforeLog((log) => {
log.metadata = { ...log.metadata, requestId: getCurrentRequestId() };
});
// Later: unregister
dispose();
HookCan mutate?Error behavior
beforeLogYesErrors abort the write — the entry is not stored.
afterLogNo (read-only snapshot)The entry is already written. In async mode, errors are passed to onError. In sync mode, errors propagate to the caller.

Hooks run sequentially in registration order. beforeLog hooks see post-enrichment, post-redaction data.

By default, captureLog() awaits the database write. Enable async mode for fire-and-forget behavior:

const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users"],
asyncWrite: true,
});

When asyncWrite is true, captureLog() returns immediately without waiting for the write to complete.

Individual captureLog() calls can override the global setting:

// Global async, but force this specific write to be synchronous
await audit.captureLog({
tableName: "users",
operation: "INSERT",
recordId: "1",
asyncWrite: false,
});

The onError callback is called when an async write or an afterLog hook fails. If not set, errors are logged to console.error with sanitized output (no PII).

const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users"],
onError: (error) => logger.error("Audit error", error),
});

In synchronous mode (asyncWrite: false), write errors and afterLog hook errors propagate to the caller as thrown exceptions. onError is only invoked for errors that cannot be thrown — async writes and async afterLog failures.

The framework middleware (Hono/Express) automatically injects the actor from the HTTP request. For code that runs outside a request — background jobs, cron tasks, CLI scripts — use audit.withContext() to set actor identity manually. You can also enrich context mid-request with mergeAuditContext() and read it with getAuditContext().

See the Actor Context guide for full details, examples, and troubleshooting.

The ORM proxy (Drizzle) and extension (Prisma) accept additional options beyond the core betterAudit() config.

src/audit.ts
import { betterAudit } from "@usebetterdev/audit";
import { drizzleAuditAdapter, withAuditProxy } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users", "orders"],
});
const auditedDb = withAuditProxy(db, audit.captureLog, {
primaryKey: "id",
onError: (error) => logger.error("Audit proxy error", error),
onMissingRecordId: "warn",
skipBeforeState: ["large_events"],
maxBeforeStateRows: 1000,
});
OptionTypeDefaultDescription
primaryKeystring"id"Fallback primary key column name for record ID extraction.
onError(error: unknown) => voidconsole.errorCalled when audit capture fails.
onMissingRecordId"warn" | "skip" | "throw""warn"What to do when the record ID cannot be determined.
skipBeforeStatestring[][]Table names to skip before-state SELECT for (improves performance for large tables).
maxBeforeStateRowsnumber1000Safety limit for before-state SELECT queries.

Connect Better Audit to UseBetter Console for a web-based audit dashboard:

src/audit.ts
import { betterConsole } from "@usebetterdev/console";
import { betterAudit } from "@usebetterdev/audit";
import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const consoleInstance = betterConsole({
connectionTokenHash: process.env.BETTER_CONSOLE_TOKEN_HASH ?? "",
sessions: { autoApprove: process.env.NODE_ENV === "development" },
});
const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users"],
console: consoleInstance,
});

This registers audit dashboard endpoints with the console automatically.

  • Actor Context — how actor identity propagates through your request lifecycle
  • Adapters — adapter-specific setup, API reference, and error handling
  • Quick Start — working example with ORM + framework middleware in one page