Skip to content

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 AsyncLocalStorage so every audit entry is tagged automatically

Install peer dependencies if you haven’t already:

Terminal window
npm install drizzle-orm pg

Wiring up the adapter:

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

OptionTypeDefaultDescription
onError(error: unknown) => voidconsole.errorCalled when audit capture fails. Errors are always swallowed.
onMissingRecordId"warn" | "skip" | "throw""warn"What to do when the primary key cannot be detected.
skipBeforeStatestring[][]Table names to skip the pre-mutation SELECT for (high-throughput tables).
maxBeforeStateRowsnumber1000If the pre-mutation SELECT returns more rows than this, skip before-state capture.
const auditedDb = withAuditProxy(db, audit.captureLog, {
onMissingRecordId: "skip",
skipBeforeState: ["events", "metrics"],
});

Install peer dependencies:

Terminal window
npm install @prisma/client

Requires @prisma/client >= 5.0.0.

lib/audit.ts
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 automatically
export 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:

OptionTypeDefaultDescription
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) => voidconsole.errorCalled when audit capture fails. Errors are always swallowed.
skipBeforeCapturestring[][]SQL table names to skip the pre-mutation lookup for.
maxBeforeStateRowsnumber100If before-state lookup exceeds this many rows, falls back to a single bulk entry.
tableNameTransform(modelName: string) => stringauto-detectOverride the model → table name mapping. Auto-detection reads @@map directives.
const auditedPrisma = withAuditExtension(prisma, audit.captureLog, {
bulkMode: "bulk",
skipBeforeCapture: ["events", "metrics"],
});

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() so getAuditContext() returns the actor anywhere in the call tree
  • Fail open — if extraction fails or yields nothing, the request proceeds without context
src/server.ts
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 default
app.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);
});
src/server.ts
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 default
app.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 has three distinct execution contexts that each need a different approach.

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 here
Section titled “Pattern 1 — Middleware + route handlers (recommended for APIs)”
middleware.ts
import { createAuditMiddleware } from "@usebetterdev/audit/next";
export default createAuditMiddleware();
export const config = { matcher: "/api/:path*" };
app/api/orders/route.ts
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.

app/api/orders/route.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 default
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);

Server actions don’t receive a Request object. withAudit reads all request headers via next/headers and constructs a synthetic request for the extractor.

app/actions.ts
"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: {} });
});

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 claim
withAuditRoute(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 session
withAudit(action, { extractor: { actor: fromCookie("session_id") } });

All adapters accept the same extractor option. The extractor receives a Web-standard Request and returns the actor identifier as a string (or undefined).

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.sub
import { fromBearerToken } from "@usebetterdev/audit";
app.use("*", betterAuditHono({ extractor: { actor: fromBearerToken("user_id") } }));

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") } }));

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") } }));

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;
},
},
}),
);

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),
}),
);

Creates an AuditDatabaseAdapter backed by a Drizzle pg database. Accepts any Drizzle database instance with insert, select, and delete support.

Creates an AuditDatabaseAdapter backed by a Drizzle SQLite database.

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.

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.

Convenience wrapper for Hono. Equivalent to createHonoMiddleware(options).

Returns a Hono-compatible middleware function (context, next) => Promise<Response | void>.

Convenience wrapper for Express. Equivalent to 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:

OptionTypeDescription
actorHeaderstringHeader name for forwarding the actor id. Defaults to AUDIT_ACTOR_HEADER.

Wraps an App Router route handler (request: NextRequest, context) => Promise<Response>. Extracts actor from the request and runs the handler inside an ALS scope.

Wraps a server action (...args) => Promise<T>. Reads all request headers via next/headers, extracts actor, and runs the action inside an ALS scope.

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):

OptionTypeDescription
extractorContextExtractorActor extractor config. Defaults to JWT sub claim.
onError(error: unknown) => voidCalled when an extractor throws. Defaults to no-op.
  • 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