Skip to content

Troubleshooting

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.

Middleware not mounted. The audit middleware must be registered before your route handlers:

src/server.ts
import { Hono } from "hono";
import { betterAuditHono } from "@usebetterdev/audit/hono";
const app = new Hono();
// Must come before route handlers
app.use("*", betterAuditHono());

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:

src/server.ts
import { fromHeader } from "@usebetterdev/audit";
import { betterAuditHono } from "@usebetterdev/audit/hono";
// Use a plain header from your API gateway
app.use("*", betterAuditHono({ extractor: { actor: fromHeader("x-user-id") } }));

Extractor returning undefined. Add onError to surface extraction failures:

src/server.ts
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:

src/jobs/cleanup.ts
import { audit } from "../audit.js";
await audit.withContext({ actorId: "system:cleanup-job" }, async () => {
// captureLog() calls here receive the actorId
await deactivateExpiredAccounts();
});

Mutations happen in the database but no audit entries appear.

The auditTables array controls which tables are audited. Mutations on tables not in this list are silently skipped:

src/audit.ts
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.

The audit proxy only intercepts mutations through the wrapped database. If you use the original db instead of auditedDb, nothing is captured:

src/audit.ts
import { withAuditProxy } from "@usebetterdev/audit/drizzle";
const auditedDb = withAuditProxy(db, audit.captureLog);
// Captured — uses audited handle
await auditedDb.insert(usersTable).values({ name: "Alice" });
// NOT captured — uses original handle
await db.insert(usersTable).values({ name: "Alice" });

Fix: Replace all db references with auditedDb in your route handlers and services.

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:

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

src/audit.ts
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.

If the table is listed in skipBeforeState, the proxy skips the pre-mutation SELECT:

src/audit.ts
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.

Same concept — Prisma skips the findUnique/findMany before the mutation:

src/audit.ts
const auditedPrisma = withAuditExtension(prisma, audit.captureLog, {
skipBeforeCapture: ["events"], // "events" table won't have beforeData
});

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:

src/audit.ts
// Drizzle
const auditedDb = withAuditProxy(db, audit.captureLog, {
maxBeforeStateRows: 5000,
});
// Prisma
const auditedPrisma = withAuditExtension(prisma, audit.captureLog, {
maxBeforeStateRows: 500,
});

The audit entry is captured but recordId is empty.

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:

src/audit.ts
const auditedDb = withAuditProxy(db, audit.captureLog, {
primaryKey: "uuid", // default is "id"
});

Use onMissingRecordId to control what happens when the record ID cannot be determined:

src/audit.ts
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 are mutually exclusive

An enrichment rule has both redact and include set. These are two different modes — use one or the other:

src/audit.ts
// Blocklist — remove specific fields, keep everything else
audit.enrich("users", "*", {
redact: ["password", "ssn"],
});
// Allowlist — keep only listed fields, remove everything else
audit.enrich("users", "*", {
include: ["id", "name", "email"],
});

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:

src/audit.ts
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");
},
});

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 the AsyncLocalStorage scope, the context is lost. Move the audit middleware earlier in the chain.

check: FAIL - table "audit_logs" not found

The migration has not been applied. Run the migration workflow:

Terminal window
# Generate the migration
npx drizzle-kit generate --custom --name=audit_logs --prefix=none
npx @usebetterdev/audit-cli migrate -o drizzle/_audit_logs.sql
# Apply it
npx drizzle-kit migrate

Verify with:

Terminal window
npx @usebetterdev/audit-cli check --database-url $DATABASE_URL

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:

Terminal window
npx @usebetterdev/audit-cli migrate --adapter drizzle --dialect postgres -o drizzle/

Performance tuning

For high-throughput tables where the pre-mutation SELECT adds unacceptable latency, skip before-state capture:

src/audit.ts
// Drizzle
const auditedDb = withAuditProxy(db, audit.captureLog, {
skipBeforeState: ["events", "metrics"],
});
// Prisma
const auditedPrisma = withAuditExtension(prisma, audit.captureLog, {
skipBeforeCapture: ["events", "metrics"],
});

The audit entry is still written — it just won’t include beforeData or the computed diff.

Enable asyncWrite globally or per-call to avoid blocking the request on the database write:

src/audit.ts
import { betterAudit } from "@usebetterdev/audit";
import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
// Global — all writes are fire-and-forget
const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users", "orders"],
asyncWrite: true,
onError: (error) => logger.error("Audit write failed", error),
});
// Per-call — force sync for critical operations
await audit.captureLog({
tableName: "payments",
operation: "DELETE",
recordId: "pay-1",
asyncWrite: false, // overrides global asyncWrite
});

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:

src/audit.ts
import { withAuditExtension } from "@usebetterdev/audit/prisma";
const auditedPrisma = withAuditExtension(prisma, audit.captureLog, {
bulkMode: "bulk",
});

check requires --database-url or DATABASE_URL environment variable

Pass the URL via flag or environment variable:

Terminal window
# Via flag
npx @usebetterdev/audit-cli check --database-url postgres://user:pass@localhost:5432/mydb
# Via environment variable
export DATABASE_URL=postgres://user:pass@localhost:5432/mydb
npx @usebetterdev/audit-cli check

The purge command permanently deletes rows. Always preview first:

Terminal window
# Preview — no rows deleted
npx @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_URL

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.

app/api/orders/route.ts
import { withAuditRoute, fromHeader } from "@usebetterdev/audit/next";
// Safe — reads from JWT, not a spoofable header
export const POST = withAuditRoute(handler);
// Unsafe without middleware — clients can set x-user-id directly
export const POST = withAuditRoute(handler, {
extractor: { actor: fromHeader("x-user-id") },
});