Quick Start
This guide walks you through adding audit logging to an existing application. By the end, every INSERT, UPDATE, and DELETE will be automatically captured — with actor tracking, before/after snapshots, and compliance tagging — stored in your own database.
Prerequisites
Section titled “Prerequisites”@usebetterdev/auditand peer dependencies installed- A running PostgreSQL 13+ database (MySQL and SQLite are also supported — see Installation)
- An existing application with tables you want to audit
Overview
Section titled “Overview”Better Audit works in three layers:
- ORM adapter — writes audit log entries to an
audit_logstable in your database - ORM proxy/extension — transparently intercepts mutations so you don’t litter your code with manual
captureLog()calls - Framework middleware — extracts the current actor (user) from each request via JWT, header, or cookie
-
Generate the
audit_logsmigrationThe CLI auto-detects your ORM and database dialect:
Terminal window # Generate a custom migration filenpx drizzle-kit generate --custom --name=audit_logs --prefix=nonenpx @usebetterdev/audit-cli migrate -o drizzle/_audit_logs.sql# Apply the migrationnpx drizzle-kit migrateTerminal window # Create a draft migration (--create-only generates the file without applying)npx prisma migrate dev --create-only --name audit_logs# Fill it with the audit_logs table DDLnpx @usebetterdev/audit-cli migrate \-o prisma/migrations/*_audit_logs/migration.sql# Apply the migrationnpx prisma migrate dev -
Wire up audit
Create the audit instance, wrap your ORM client for automatic capture, and add actor tracking middleware.
src/server.ts import { drizzle } from "drizzle-orm/node-postgres";import { Pool } from "pg";import { Hono } from "hono";import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter, withAuditProxy } from "@usebetterdev/audit/drizzle";import { betterAuditHono } from "@usebetterdev/audit/hono";import { usersTable } from "./schema";const pool = new Pool({ connectionString: process.env.DATABASE_URL });const db = drizzle(pool);// 1. Create audit instanceconst audit = betterAudit({database: drizzleAuditAdapter(db),auditTables: ["users"],});// 2. Wrap the database for automatic captureconst auditedDb = withAuditProxy(db, audit.captureLog);// 3. Set up the app with actor trackingconst app = new Hono();app.use("*", betterAuditHono());app.post("/users", async (c) => {const body = await c.req.json();await auditedDb.insert(usersTable).values(body);return c.json({ ok: true });});src/server.ts import { drizzle } from "drizzle-orm/node-postgres";import { Pool } from "pg";import express from "express";import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter, withAuditProxy } from "@usebetterdev/audit/drizzle";import { betterAuditExpress } from "@usebetterdev/audit/express";import { usersTable } from "./schema";const pool = new Pool({ connectionString: process.env.DATABASE_URL });const db = drizzle(pool);// 1. Create audit instanceconst audit = betterAudit({database: drizzleAuditAdapter(db),auditTables: ["users"],});// 2. Wrap the database for automatic captureconst auditedDb = withAuditProxy(db, audit.captureLog);// 3. Set up the app with actor trackingconst app = express();app.use(express.json());app.use(betterAuditExpress());app.post("/users", async (req, res, next) => {try {await auditedDb.insert(usersTable).values(req.body);res.json({ ok: true });} catch (error) {next(error);}});src/server.ts import { PrismaClient } from "./generated/prisma/client.js";import { Hono } from "hono";import { betterAudit } from "@usebetterdev/audit";import { prismaAuditAdapter, withAuditExtension } from "@usebetterdev/audit/prisma";import { betterAuditHono } from "@usebetterdev/audit/hono";const prisma = new PrismaClient();// 1. Create audit instanceconst audit = betterAudit({database: prismaAuditAdapter(prisma),auditTables: ["users"],});// 2. Extend Prisma for automatic captureconst auditedPrisma = withAuditExtension(prisma, audit.captureLog);// 3. Set up the app with actor trackingconst app = new Hono();app.use("*", betterAuditHono());app.post("/users", async (c) => {const body = await c.req.json();await auditedPrisma.user.create({ data: body });return c.json({ ok: true });});src/server.ts import { PrismaClient } from "./generated/prisma/client.js";import express from "express";import { betterAudit } from "@usebetterdev/audit";import { prismaAuditAdapter, withAuditExtension } from "@usebetterdev/audit/prisma";import { betterAuditExpress } from "@usebetterdev/audit/express";const prisma = new PrismaClient();// 1. Create audit instanceconst audit = betterAudit({database: prismaAuditAdapter(prisma),auditTables: ["users"],});// 2. Extend Prisma for automatic captureconst auditedPrisma = withAuditExtension(prisma, audit.captureLog);// 3. Set up the app with actor trackingconst app = express();app.use(express.json());app.use(betterAuditExpress());app.post("/users", async (req, res, next) => {try {await auditedPrisma.user.create({ data: req.body });res.json({ ok: true });} catch (error) {next(error);}});middleware.ts import { createAuditMiddleware } from "@usebetterdev/audit/next";// Extracts actor from JWT, forwards as x-better-audit-actor-id headerexport default createAuditMiddleware();export const config = { matcher: "/api/:path*" };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";const pool = new Pool({ connectionString: process.env.DATABASE_URL });export const db = drizzle(pool);export const audit = betterAudit({database: drizzleAuditAdapter(db),auditTables: ["users"],});export const auditedDb = withAuditProxy(db, audit.captureLog);app/api/users/route.ts import { NextRequest } from "next/server";import { withAuditRoute, fromHeader, AUDIT_ACTOR_HEADER } from "@usebetterdev/audit/next";import { auditedDb } from "@/lib/audit";import { usersTable } from "@/schema";async function handler(request: NextRequest) {const body = await request.json();await auditedDb.insert(usersTable).values(body);return Response.json({ ok: true });}// Reads actor from the header set by middleware.tsexport const POST = withAuditRoute(handler, {extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },});All adapters extract the
subclaim fromAuthorization: Bearer <jwt>by default. To customize: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") },})); -
Test it
Make a mutation and then query the audit log:
Terminal window # Create a user (triggers audit capture)curl -X POST http://localhost:3000/users \-H "Content-Type: application/json" \-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTQyIn0.abc" \-d '{"id": "1", "name": "Alice"}'Query the logs programmatically:
const result = await audit.query().resource("users").since("1h").list();console.log(result.entries);// [{ id: "...", tableName: "users", operation: "INSERT",// recordId: "1", actorId: "user-42", afterData: { id: "1", name: "Alice" }, ... }]Or export via the CLI:
Terminal window npx @usebetterdev/audit-cli export --since 1h --format jsonYou should see output like:
[{"id": "a1b2c3","timestamp": "2025-01-15T10:30:00.000Z","tableName": "users","operation": "INSERT","recordId": "1","actorId": "user-42","afterData": { "id": "1", "name": "Alice" }}]
What just happened?
Section titled “What just happened?”- The CLI generated the
audit_logstable in your database — a single table with columns for timestamps, operations, before/after snapshots, actor IDs, and compliance metadata. - The ORM proxy (Drizzle) or extension (Prisma) transparently intercepts every
INSERT,UPDATE, andDELETEand writes an audit log entry. - The framework middleware (Hono or Express) extracted the actor ID from the JWT and stored it in
AsyncLocalStorage. The audit log entry was tagged withactorId: "user-42"without you passing it explicitly. - All audit data lives in your database — no external service, no vendor lock-in.
Optional: enrichment and compliance
Section titled “Optional: enrichment and compliance”Add human-readable labels, severity levels, and compliance tags to specific operations:
audit.enrich("users", "DELETE", { label: "User account deleted", severity: "critical", compliance: ["gdpr", "soc2"], redact: ["password", "ssn"],});
audit.enrich("users", "UPDATE", { label: "User profile updated", severity: "medium", description: ({ before, after, actorId }) => `Actor ${actorId} changed user name from ${before?.name} to ${after?.name}`,});Optional: retention policy
Section titled “Optional: retention policy”Automatically purge old audit entries:
const audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users"], retention: { days: 365 },});Then run the purge command on a schedule:
npx @usebetterdev/audit-cli purgeNext steps
Section titled “Next steps”- How Audit Works — interactive walkthrough of the ORM proxy → actor context → audit_logs pipeline
- Adapters — ORM adapter setup, automatic capture, custom extractors, and error handling
- CLI & Migrations — generate the audit_logs migration, verify setup, export data, and purge old entries