Skip to content

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.

  • @usebetterdev/audit and 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

Better Audit works in three layers:

  1. ORM adapter — writes audit log entries to an audit_logs table in your database
  2. ORM proxy/extension — transparently intercepts mutations so you don’t litter your code with manual captureLog() calls
  3. Framework middleware — extracts the current actor (user) from each request via JWT, header, or cookie
  1. Generate the audit_logs migration

    The CLI auto-detects your ORM and database dialect:

    Terminal window
    # Generate a custom migration file
    npx drizzle-kit generate --custom --name=audit_logs --prefix=none
    npx @usebetterdev/audit-cli migrate -o drizzle/_audit_logs.sql
    # Apply the migration
    npx drizzle-kit migrate
  2. 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 instance
    const audit = betterAudit({
    database: drizzleAuditAdapter(db),
    auditTables: ["users"],
    });
    // 2. Wrap the database for automatic capture
    const auditedDb = withAuditProxy(db, audit.captureLog);
    // 3. Set up the app with actor tracking
    const 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 });
    });

    All adapters extract the sub claim from Authorization: Bearer <jwt> by default. To customize:

    import { fromHeader } from "@usebetterdev/audit";
    app.use("*", betterAuditHono({
    extractor: { actor: fromHeader("x-user-id") },
    }));
  3. 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 json

    You 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" }
    }
    ]
  1. The CLI generated the audit_logs table in your database — a single table with columns for timestamps, operations, before/after snapshots, actor IDs, and compliance metadata.
  2. The ORM proxy (Drizzle) or extension (Prisma) transparently intercepts every INSERT, UPDATE, and DELETE and writes an audit log entry.
  3. The framework middleware (Hono or Express) extracted the actor ID from the JWT and stored it in AsyncLocalStorage. The audit log entry was tagged with actorId: "user-42" without you passing it explicitly.
  4. All audit data lives in your database — no external service, no vendor lock-in.

Add human-readable labels, severity levels, and compliance tags to specific operations:

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

Automatically purge old audit entries:

src/audit.ts
const audit = betterAudit({
database: drizzleAuditAdapter(db),
auditTables: ["users"],
retention: { days: 365 },
});

Then run the purge command on a schedule:

Terminal window
npx @usebetterdev/audit-cli purge
  • 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