Adapters
Better Audit has two kinds of adapters:
- ORM adapters — connect audit to your database (
drizzleAuditAdapter,prismaAuditAdapter) and intercept mutations for automatic capture (withAuditProxy,withAuditExtension) - Framework adapters — middleware that extracts the current actor from each HTTP request and stores it in
AsyncLocalStorageso every audit entry is tagged automatically
ORM adapters
Section titled “ORM adapters”Drizzle
Section titled “Drizzle”Install peer dependencies if you haven’t already:
npm install drizzle-orm pgnpm install drizzle-orm postgresnpm install drizzle-orm better-sqlite3Wiring up the adapter:
import { drizzle } from "drizzle-orm/node-postgres";import { Pool } from "pg";import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter, withAuditProxy } from "@usebetterdev/audit/drizzle";import { usersTable } from "./schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });const db = drizzle(pool);
export const audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users"],});
// Wrap db — insert/update/delete are now captured automaticallyexport const auditedDb = withAuditProxy(db, audit.captureLog);import { drizzle } from "drizzle-orm/postgres-js";import postgres from "postgres";import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter, withAuditProxy } from "@usebetterdev/audit/drizzle";
const client = postgres(process.env.DATABASE_URL);const db = drizzle(client);
export const audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users"],});
export const auditedDb = withAuditProxy(db, audit.captureLog);import Database from "better-sqlite3";import { drizzle } from "drizzle-orm/better-sqlite3";import { betterAudit } from "@usebetterdev/audit";import { drizzleSqliteAuditAdapter, withAuditProxy } from "@usebetterdev/audit/drizzle";
const sqlite = new Database("./dev.db");const db = drizzle(sqlite);
export const audit = betterAudit({ database: drizzleSqliteAuditAdapter(db), auditTables: ["users"],});
export const auditedDb = withAuditProxy(db, audit.captureLog);drizzleAuditAdapter(db) connects audit to the audit_logs table — it handles writeLog, queryLogs, getLogById, getStats, and purgeLogs.
withAuditProxy(db, captureLog) wraps the Drizzle database with a transparent proxy that intercepts db.insert(), db.update(), and db.delete(). It also wraps db.transaction() so the proxy carries into nested transactions. The proxy reads the before-state via a SELECT before each UPDATE or DELETE.
Proxy options:
| Option | Type | Default | Description |
|---|---|---|---|
onError | (error: unknown) => void | console.error | Called when audit capture fails. Errors are always swallowed. |
onMissingRecordId | "warn" | "skip" | "throw" | "warn" | What to do when the primary key cannot be detected. |
skipBeforeState | string[] | [] | Table names to skip the pre-mutation SELECT for (high-throughput tables). |
maxBeforeStateRows | number | 1000 | If the pre-mutation SELECT returns more rows than this, skip before-state capture. |
const auditedDb = withAuditProxy(db, audit.captureLog, { onMissingRecordId: "skip", skipBeforeState: ["events", "metrics"],});Prisma
Section titled “Prisma”Install peer dependencies:
npm install @prisma/clientRequires @prisma/client >= 5.0.0.
import { PrismaClient } from "./generated/prisma/client.js";import { betterAudit } from "@usebetterdev/audit";import { prismaAuditAdapter, withAuditExtension } from "@usebetterdev/audit/prisma";
const prisma = new PrismaClient();
export const audit = betterAudit({ database: prismaAuditAdapter(prisma), auditTables: ["users"],});
// Extend Prisma — all mutations are now captured automaticallyexport const auditedPrisma = withAuditExtension(prisma, audit.captureLog);prismaAuditAdapter(prisma) connects audit to the audit_logs table using $executeRawUnsafe and $queryRawUnsafe — no Prisma model is generated for audit_logs. It handles writeLog, queryLogs, getLogById, getStats, and purgeLogs.
withAuditExtension(prisma, captureLog) uses Prisma’s $extends API to intercept all mutations across all models. For update and upsert, a findUnique is issued before the mutation to capture before-state. For updateMany and deleteMany, a findMany captures per-row state up to maxBeforeStateRows.
Extension options:
| Option | Type | Default | Description |
|---|---|---|---|
bulkMode | "per-row" | "bulk" | "per-row" | "per-row" emits one audit entry per row; "bulk" emits a single entry for the whole operation. |
onError | (error: unknown) => void | console.error | Called when audit capture fails. Errors are always swallowed. |
skipBeforeCapture | string[] | [] | SQL table names to skip the pre-mutation lookup for. |
maxBeforeStateRows | number | 100 | If before-state lookup exceeds this many rows, falls back to a single bulk entry. |
tableNameTransform | (modelName: string) => string | auto-detect | Override the model → table name mapping. Auto-detection reads @@map directives. |
const auditedPrisma = withAuditExtension(prisma, audit.captureLog, { bulkMode: "bulk", skipBeforeCapture: ["events", "metrics"],});Framework adapters
Section titled “Framework adapters”The framework adapter’s job is to extract the current actor (user or service) from each HTTP request and store it in AsyncLocalStorage. Every audit entry captured during that request automatically receives the actor — you don’t pass it explicitly.
All adapters share the same behaviour:
- Extract actor via the configured
ContextExtractor - Wrap the request handler inside
runWithAuditContext()sogetAuditContext()returns the actor anywhere in the call tree - Fail open — if extraction fails or yields nothing, the request proceeds without context
import { Hono } from "hono";import { betterAuditHono } from "@usebetterdev/audit/hono";import { auditedDb } from "./lib/audit";import { usersTable } from "./schema";
const app = new Hono();
// Reads `sub` from Authorization: Bearer <jwt> by defaultapp.use("*", betterAuditHono());
app.post("/users", async (c) => { const body = await c.req.json(); // actorId is automatically attached from the JWT await auditedDb.insert(usersTable).values(body); return c.json({ ok: true }, 201);});Express
Section titled “Express”import express from "express";import { betterAuditExpress } from "@usebetterdev/audit/express";import { auditedDb } from "./lib/audit";import { usersTable } from "./schema";
const app = express();app.use(express.json());
// Reads `sub` from Authorization: Bearer <jwt> by defaultapp.use(betterAuditExpress());
app.post("/users", async (req, res, next) => { try { // actorId is automatically attached from the JWT await auditedDb.insert(usersTable).values(req.body); res.status(201).json({ ok: true }); } catch (error) { next(error); }});Next.js App Router
Section titled “Next.js App Router”Next.js App Router has three distinct execution contexts that each need a different approach.
The ALS propagation constraint
Section titled “The ALS propagation constraint”Next.js edge middleware runs in a separate V8 isolate from route handlers. AsyncLocalStorage set in middleware does not carry over into route handlers or server actions.
The solution is a two-part pattern: middleware extracts the actor and forwards it as a request header; the route handler wrapper reads that header and sets up ALS.
request → Edge Middleware: extract actor → set x-better-audit-actor-id header → Route Handler: read header → runWithAuditContext() → getAuditContext() available herePattern 1 — Middleware + route handlers (recommended for APIs)
Section titled “Pattern 1 — Middleware + route handlers (recommended for APIs)”import { createAuditMiddleware } from "@usebetterdev/audit/next";
export default createAuditMiddleware();export const config = { matcher: "/api/:path*" };import { NextRequest } from "next/server";import { withAuditRoute, fromHeader, AUDIT_ACTOR_HEADER } from "@usebetterdev/audit/next";import { auditedDb } from "@/lib/audit";import { ordersTable } from "@/schema";
async function handler(request: NextRequest) { const body = await request.json(); await auditedDb.insert(ordersTable).values(body); return Response.json({ ok: true });}
export const POST = withAuditRoute(handler, { extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },});Use AUDIT_ACTOR_HEADER (the exported constant "x-better-audit-actor-id") in both places so the header name never gets out of sync.
Pattern 2 — Route handler only (no middleware)
Section titled “Pattern 2 — Route handler only (no middleware)”Skip the middleware entirely and extract from the request directly. Useful for standalone routes or apps without a middleware.ts.
import { NextRequest } from "next/server";import { withAuditRoute } from "@usebetterdev/audit/next";import { auditedDb } from "@/lib/audit";import { ordersTable } from "@/schema";
// Reads `sub` from Authorization: Bearer <jwt> by defaultasync function handler(request: NextRequest) { const body = await request.json(); await auditedDb.insert(ordersTable).values(body); return Response.json({ ok: true });}
export const POST = withAuditRoute(handler);Pattern 3 — Server actions
Section titled “Pattern 3 — Server actions”Server actions don’t receive a Request object. withAudit reads all request headers via next/headers and constructs a synthetic request for the extractor.
"use server";import { withAudit, getAuditContext } from "@usebetterdev/audit/next";import { audit } from "@/lib/audit";
export const createOrder = withAudit(async (formData: FormData) => { const ctx = getAuditContext(); // { actorId: "user-123" } await audit.captureLog({ tableName: "orders", operation: "INSERT", recordId: "ord-1", after: {} });});Custom extractors
Section titled “Custom extractors”All three wrappers accept the same extractor option. The extractor receives a Web-standard Request and returns a string or undefined.
import { NextRequest } from "next/server";import { withAuditRoute, withAudit, fromBearerToken, fromHeader, fromCookie } from "@usebetterdev/audit/next";
// Different JWT claimwithAuditRoute(handler, { extractor: { actor: fromBearerToken("user_id") } });
// Plain header (e.g. from a trusted API gateway)withAuditRoute(handler, { extractor: { actor: fromHeader("x-user-id") } });
// Cookie-based sessionwithAudit(action, { extractor: { actor: fromCookie("session_id") } });Actor extraction
Section titled “Actor extraction”All adapters accept the same extractor option. The extractor receives a Web-standard Request and returns the actor identifier as a string (or undefined).
Default: JWT Bearer token
Section titled “Default: JWT Bearer token”With no options, all adapters decode sub from the Authorization: Bearer <jwt> header. The token is decoded without signature verification — that is the auth layer’s responsibility.
app.use("*", betterAuditHono());// Authorization: Bearer eyJ... → actorId = jwt.subapp.use(betterAuditExpress());// Authorization: Bearer eyJ... → actorId = jwt.subexport default createAuditMiddleware();// Authorization: Bearer eyJ... → actorId = jwt.subCustom JWT claim
Section titled “Custom JWT claim”import { fromBearerToken } from "@usebetterdev/audit";
app.use("*", betterAuditHono({ extractor: { actor: fromBearerToken("user_id") } }));import { fromBearerToken } from "@usebetterdev/audit";
app.use(betterAuditExpress({ extractor: { actor: fromBearerToken("user_id") } }));import { fromBearerToken } from "@usebetterdev/audit/next";
export default createAuditMiddleware({ extractor: { actor: fromBearerToken("user_id") } });Header-based extraction
Section titled “Header-based extraction”Use fromHeader when the actor identity is passed as a plain request header (common behind API gateways):
import { fromHeader } from "@usebetterdev/audit";
app.use("*", betterAuditHono({ extractor: { actor: fromHeader("x-user-id") } }));import { fromHeader } from "@usebetterdev/audit";
app.use(betterAuditExpress({ extractor: { actor: fromHeader("x-user-id") } }));import { fromHeader } from "@usebetterdev/audit/next";
// Route handler only — reads actor from a gateway-injected headerexport const GET = withAuditRoute(handler, { extractor: { actor: fromHeader("x-user-id") },});Cookie-based extraction
Section titled “Cookie-based extraction”Use fromCookie for session-based auth where the actor ID lives in a cookie:
import { fromCookie } from "@usebetterdev/audit";
app.use("*", betterAuditHono({ extractor: { actor: fromCookie("session_id") } }));import { fromCookie } from "@usebetterdev/audit";
app.use(betterAuditExpress({ extractor: { actor: fromCookie("session_id") } }));import { fromCookie } from "@usebetterdev/audit/next";
// Works in withAudit (server actions) — cookies() are read via next/headersexport const createOrder = withAudit(action, { extractor: { actor: fromCookie("session_id") },});Custom extractor function
Section titled “Custom extractor function”Write your own ValueExtractor for full control. It receives a Web-standard Request and returns a string or undefined:
app.use( "*", betterAuditHono({ extractor: { actor: async (request) => { const apiKey = request.headers.get("x-api-key"); if (!apiKey) return undefined; const owner = await resolveApiKeyOwner(apiKey); return owner?.id; }, }, }),);app.use( betterAuditExpress({ extractor: { actor: async (request) => { const apiKey = request.headers.get("x-api-key"); if (!apiKey) return undefined; const owner = await resolveApiKeyOwner(apiKey); return owner?.id; }, }, }),);export const POST = withAuditRoute(handler, { extractor: { actor: async (request) => { const apiKey = request.headers.get("x-api-key"); if (!apiKey) return undefined; const owner = await resolveApiKeyOwner(apiKey); return owner?.id; }, },});Error handling
Section titled “Error handling”Extraction errors never break the request. By default all adapters fail open — if an extractor throws, the request proceeds without audit context.
Use onError to log or report extraction failures:
app.use( "*", betterAuditHono({ onError: (error) => console.error("Audit extraction failed:", error), }),);app.use( betterAuditExpress({ onError: (error) => console.error("Audit extraction failed:", error), }),);export default createAuditMiddleware({ onError: (error) => console.error("Audit extraction failed:", error),});API reference
Section titled “API reference”drizzleAuditAdapter(db)
Section titled “drizzleAuditAdapter(db)”Creates an AuditDatabaseAdapter backed by a Drizzle pg database. Accepts any Drizzle database instance with insert, select, and delete support.
drizzleSqliteAuditAdapter(db)
Section titled “drizzleSqliteAuditAdapter(db)”Creates an AuditDatabaseAdapter backed by a Drizzle SQLite database.
withAuditProxy(db, captureLog, options?)
Section titled “withAuditProxy(db, captureLog, options?)”Wraps a Drizzle database (or transaction) with a transparent proxy that intercepts insert, update, and delete. Returns a new database handle with identical types — use it everywhere in place of the original db. The proxy propagates into nested db.transaction() calls automatically.
prismaAuditAdapter(prisma)
Section titled “prismaAuditAdapter(prisma)”Creates an AuditDatabaseAdapter backed by a Prisma client. Uses $executeRawUnsafe and $queryRawUnsafe for precise PostgreSQL type casting — all user-supplied values are passed as bound parameters, never string-interpolated. No Prisma schema changes required.
withAuditExtension(prisma, captureLog, options?)
Section titled “withAuditExtension(prisma, captureLog, options?)”Wraps a Prisma client using $extends to intercept all mutations across all models. Returns a new extended client of the same type — use it everywhere in place of the original prisma.
betterAuditHono(options?)
Section titled “betterAuditHono(options?)”Convenience wrapper for Hono. Equivalent to createHonoMiddleware(options).
createHonoMiddleware(options?)
Section titled “createHonoMiddleware(options?)”Returns a Hono-compatible middleware function (context, next) => Promise<Response | void>.
betterAuditExpress(options?)
Section titled “betterAuditExpress(options?)”Convenience wrapper for Express. Equivalent to createExpressMiddleware(options).
createExpressMiddleware(options?)
Section titled “createExpressMiddleware(options?)”Returns an Express-compatible middleware function (req, res, next) => Promise<void>.
betterAuditNext(options?) / createAuditMiddleware(options?)
Section titled “betterAuditNext(options?) / createAuditMiddleware(options?)”Returns a Next.js edge middleware function (request: NextRequest) => Promise<NextResponse>. Always overwrites the actor header on the forwarded request to prevent client spoofing.
Additional option:
| Option | Type | Description |
|---|---|---|
actorHeader | string | Header name for forwarding the actor id. Defaults to AUDIT_ACTOR_HEADER. |
withAuditRoute(handler, options?)
Section titled “withAuditRoute(handler, options?)”Wraps an App Router route handler (request: NextRequest, context) => Promise<Response>. Extracts actor from the request and runs the handler inside an ALS scope.
withAudit(action, options?)
Section titled “withAudit(action, options?)”Wraps a server action (...args) => Promise<T>. Reads all request headers via next/headers, extracts actor, and runs the action inside an ALS scope.
AUDIT_ACTOR_HEADER
Section titled “AUDIT_ACTOR_HEADER”Exported string constant "x-better-audit-actor-id" — the default header used to forward the actor id between middleware and route handlers.
Shared options (all framework adapters):
| Option | Type | Description |
|---|---|---|
extractor | ContextExtractor | Actor extractor config. Defaults to JWT sub claim. |
onError | (error: unknown) => void | Called when an extractor throws. Defaults to no-op. |
Next steps
Section titled “Next steps”- Actor Context — how context propagates, enriching mid-request, and troubleshooting
- Configuration — enrichment, retention, hooks, and ORM adapter tuning
- Troubleshooting — common issues with capture, actor context, and migrations
- Quick Start — working example with ORM + framework middleware in one page