Troubleshooting
actorId is undefined in logs
Section titled “actorId is undefined in logs”Audit entries are written but actorId is undefined (or null in the database). This means the framework middleware could not extract an actor from the request — or the middleware was not mounted.
Common causes
Section titled “Common causes”Middleware not mounted. The audit middleware must be registered before your route handlers:
import { Hono } from "hono";import { betterAuditHono } from "@usebetterdev/audit/hono";
const app = new Hono();
// Must come before route handlersapp.use("*", betterAuditHono());import express from "express";import { betterAuditExpress } from "@usebetterdev/audit/express";
const app = express();
// Must come before route handlersapp.use(betterAuditExpress());import { createAuditMiddleware } from "@usebetterdev/audit/next";
export default createAuditMiddleware();export const config = { matcher: "/api/:path*" };Token or header missing from request. The default extractor reads sub from an Authorization: Bearer <jwt> header. If your requests don’t carry a JWT, configure a different extractor:
import { fromHeader } from "@usebetterdev/audit";import { betterAuditHono } from "@usebetterdev/audit/hono";
// Use a plain header from your API gatewayapp.use("*", betterAuditHono({ extractor: { actor: fromHeader("x-user-id") } }));Extractor returning undefined. Add onError to surface extraction failures:
import { betterAuditHono } from "@usebetterdev/audit/hono";
app.use("*", betterAuditHono({ onError: (error) => console.error("Audit extraction failed:", error),}));Code outside the request scope. getAuditContext() returns undefined when called outside a request — for example, in a module-level initializer or a background job. Use audit.withContext() for non-request code:
import { audit } from "../audit.js";
await audit.withContext({ actorId: "system:cleanup-job" }, async () => { // captureLog() calls here receive the actorId await deactivateExpiredAccounts();});Events not being captured
Section titled “Events not being captured”Mutations happen in the database but no audit entries appear.
Table not in auditTables
Section titled “Table not in auditTables”The auditTables array controls which tables are audited. Mutations on tables not in this list are silently skipped:
import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users", "orders"],});
// INSERT into "users" → captured// INSERT into "payments" → silently skipped (not in auditTables)Fix: Add the missing table name to auditTables. Use the SQL table name, not the ORM model name.
Not using the audited database handle
Section titled “Not using the audited database handle”The audit proxy only intercepts mutations through the wrapped database. If you use the original db instead of auditedDb, nothing is captured:
import { withAuditProxy } from "@usebetterdev/audit/drizzle";
const auditedDb = withAuditProxy(db, audit.captureLog);
// Captured — uses audited handleawait auditedDb.insert(usersTable).values({ name: "Alice" });
// NOT captured — uses original handleawait db.insert(usersTable).values({ name: "Alice" });Fix: Replace all db references with auditedDb in your route handlers and services.
Prisma model name vs SQL table name
Section titled “Prisma model name vs SQL table name”With Prisma, auditTables uses the SQL table name (from @@map), not the Prisma model name. If your model is User but @@map("users") maps it to users, use "users" in auditTables:
import { betterAudit } from "@usebetterdev/audit";import { prismaAuditAdapter } from "@usebetterdev/audit/prisma";
const audit = betterAudit({ database: prismaAuditAdapter(prisma), auditTables: ["users"], // SQL table name, not "User"});The withAuditExtension auto-detects the model-to-table mapping from _runtimeDataModel. If auto-detection fails, use tableNameTransform:
import { withAuditExtension } from "@usebetterdev/audit/prisma";
const auditedPrisma = withAuditExtension(prisma, audit.captureLog, { tableNameTransform: (modelName) => modelName.toLowerCase() + "s",});beforeData is missing for updates and deletes
Section titled “beforeData is missing for updates and deletes”Audit entries for UPDATE and DELETE operations show null for beforeData.
Drizzle: table in skipBeforeState
Section titled “Drizzle: table in skipBeforeState”If the table is listed in skipBeforeState, the proxy skips the pre-mutation SELECT:
const auditedDb = withAuditProxy(db, audit.captureLog, { skipBeforeState: ["events"], // "events" table won't have beforeData});Fix: Remove the table from skipBeforeState if you need before-state capture.
Prisma: table in skipBeforeCapture
Section titled “Prisma: table in skipBeforeCapture”Same concept — Prisma skips the findUnique/findMany before the mutation:
const auditedPrisma = withAuditExtension(prisma, audit.captureLog, { skipBeforeCapture: ["events"], // "events" table won't have beforeData});Too many rows affected
Section titled “Too many rows affected”When an UPDATE or DELETE affects more rows than maxBeforeStateRows, the before-state capture is skipped to avoid expensive queries. The default limits are 1000 (Drizzle) and 100 (Prisma).
Fix: Increase maxBeforeStateRows if your use case requires it, but be aware of the performance impact:
// Drizzleconst auditedDb = withAuditProxy(db, audit.captureLog, { maxBeforeStateRows: 5000,});
// Prismaconst auditedPrisma = withAuditExtension(prisma, audit.captureLog, { maxBeforeStateRows: 500,});recordId is empty or missing
Section titled “recordId is empty or missing”The audit entry is captured but recordId is empty.
Primary key not detected
Section titled “Primary key not detected”The Drizzle proxy tries to extract the primary key from the mutation result. If the table uses a column name other than id, set primaryKey:
const auditedDb = withAuditProxy(db, audit.captureLog, { primaryKey: "uuid", // default is "id"});Controlling the behavior
Section titled “Controlling the behavior”Use onMissingRecordId to control what happens when the record ID cannot be determined:
const auditedDb = withAuditProxy(db, audit.captureLog, { onMissingRecordId: "warn", // log a warning (default) // onMissingRecordId: "skip", // skip the audit entry entirely // onMissingRecordId: "throw", // throw an error});redact and include conflict
Section titled “redact and include conflict”redact and include are mutually exclusiveAn enrichment rule has both redact and include set. These are two different modes — use one or the other:
// Blocklist — remove specific fields, keep everything elseaudit.enrich("users", "*", { redact: ["password", "ssn"],});
// Allowlist — keep only listed fields, remove everything elseaudit.enrich("users", "*", { include: ["id", "name", "email"],});Async write errors are silent
Section titled “Async write errors are silent”When asyncWrite is true, captureLog() returns immediately without waiting for the write. If the write fails, the error is swallowed by default.
Also check beforeLog hooks — if a beforeLog hook throws, the entry is silently dropped regardless of asyncWrite.
Fix: Always configure onError when using async writes:
import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users"], asyncWrite: true, onError: (error) => { logger.error("Audit write failed", error); metrics.increment("audit.write_errors"); },});Context lost after await (Express)
Section titled “Context lost after await (Express)”The Express adapter extends the AsyncLocalStorage scope to cover the full response lifecycle. If context disappears after an await, check:
- Middleware order.
betterAuditExpress()must be mounted before any middleware or route handler that needs context. - Custom wrappers. If another middleware wraps
next()in a way that detaches from theAsyncLocalStoragescope, the context is lost. Move the audit middleware earlier in the chain.
Migration issues
Section titled “Migration issues”audit_logs table not found
Section titled “audit_logs table not found”check: FAIL - table "audit_logs" not foundThe migration has not been applied. Run the migration workflow:
# Generate the migrationnpx drizzle-kit generate --custom --name=audit_logs --prefix=nonenpx @usebetterdev/audit-cli migrate -o drizzle/_audit_logs.sql
# Apply itnpx drizzle-kit migrate# Create a draft migrationnpx prisma migrate dev --create-only --name audit_logs
# Fill it with the audit DDLnpx @usebetterdev/audit-cli migrate \ -o prisma/migrations/*_audit_logs/migration.sql
# Apply itnpx prisma migrate devVerify with:
npx @usebetterdev/audit-cli check --database-url $DATABASE_URLORM or dialect auto-detection fails
Section titled “ORM or dialect auto-detection fails”The CLI infers the ORM from installed packages (drizzle-orm, @prisma/client) and the dialect from DATABASE_URL. If auto-detection fails, pass the flags explicitly:
npx @usebetterdev/audit-cli migrate --adapter drizzle --dialect postgres -o drizzle/Performance tuning
Before-state SELECTs are slow
Section titled “Before-state SELECTs are slow”For high-throughput tables where the pre-mutation SELECT adds unacceptable latency, skip before-state capture:
// Drizzleconst auditedDb = withAuditProxy(db, audit.captureLog, { skipBeforeState: ["events", "metrics"],});
// Prismaconst auditedPrisma = withAuditExtension(prisma, audit.captureLog, { skipBeforeCapture: ["events", "metrics"],});The audit entry is still written — it just won’t include beforeData or the computed diff.
Async writes for non-critical tables
Section titled “Async writes for non-critical tables”Enable asyncWrite globally or per-call to avoid blocking the request on the database write:
import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
// Global — all writes are fire-and-forgetconst audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users", "orders"], asyncWrite: true, onError: (error) => logger.error("Audit write failed", error),});
// Per-call — force sync for critical operationsawait audit.captureLog({ tableName: "payments", operation: "DELETE", recordId: "pay-1", asyncWrite: false, // overrides global asyncWrite});Bulk mode for Prisma
Section titled “Bulk mode for Prisma”When createMany, updateMany, or deleteMany affect many rows, "per-row" mode (the default) creates one audit entry per row. Switch to "bulk" mode for a single entry per operation:
import { withAuditExtension } from "@usebetterdev/audit/prisma";
const auditedPrisma = withAuditExtension(prisma, audit.captureLog, { bulkMode: "bulk",});CLI errors
Section titled “CLI errors”Database URL required
Section titled “Database URL required”check requires --database-url or DATABASE_URL environment variablePass the URL via flag or environment variable:
# Via flagnpx @usebetterdev/audit-cli check --database-url postgres://user:pass@localhost:5432/mydb
# Via environment variableexport DATABASE_URL=postgres://user:pass@localhost:5432/mydbnpx @usebetterdev/audit-cli checkPurge safety
Section titled “Purge safety”The purge command permanently deletes rows. Always preview first:
# Preview — no rows deletednpx @usebetterdev/audit-cli purge --dry-run --days 90 --database-url $DATABASE_URL
# Delete (prompts for confirmation)npx @usebetterdev/audit-cli purge --days 90 --database-url $DATABASE_URL
# Non-interactive (CI/cron)npx @usebetterdev/audit-cli purge --days 90 --yes --database-url $DATABASE_URLWrong actor in audit logs (Next.js)
Section titled “Wrong actor in audit logs (Next.js)”Audit entries show an unexpected actorId — or an actor you didn’t set. This can happen when clients spoof the actor header.
createAuditMiddleware always overwrites the x-better-audit-actor-id header on the forwarded request — even when extraction fails (it sets it to ""). This prevents clients from injecting a fake actor by sending the header directly.
import { withAuditRoute, fromHeader } from "@usebetterdev/audit/next";
// Safe — reads from JWT, not a spoofable headerexport const POST = withAuditRoute(handler);
// Unsafe without middleware — clients can set x-user-id directlyexport const POST = withAuditRoute(handler, { extractor: { actor: fromHeader("x-user-id") },});