Querying & Export
Better Audit exposes a fluent, immutable query builder for reading audit logs and a streaming export engine for generating CSV and JSON downloads. Both are powered by the same underlying adapter — the ORM package handles the SQL, the builder handles what to ask for.
Querying audit logs
Section titled “Querying audit logs”audit.query() returns an AuditQueryBuilder. Each method call returns a new builder instance — builders are safe to fork, share, and reuse.
Basic query
Section titled “Basic query”import { audit } from "../audit.js";
// Fetch up to 1000 entries (the default limit)const result = await audit.query().list();
console.log(result.entries); // AuditLog[]console.log(result.nextCursor); // string | undefined — present when more pages existFiltering
Section titled “Filtering”All filter methods are chainable. Within a single method, multiple values use OR semantics (entry matches any). Across different methods, conditions use AND semantics (entry must satisfy all).
const result = await audit.query() .resource("users") // table: users .actor("user-42", "user-99") // actorId is user-42 OR user-99 .operation("DELETE") // operation is DELETE .severity("high", "critical") // severity is high OR critical .list();Filter by resource
Section titled “Filter by resource”.resource(tableName, recordId?) narrows to a specific table, and optionally a specific row:
// All events for the users tableaudit.query().resource("users")
// All events for a specific user recordaudit.query().resource("users", "user-42")Filter by actor
Section titled “Filter by actor”.actor(...ids) accepts one or more actor IDs (OR semantics, deduplicates):
audit.query().actor("user-42")audit.query().actor("user-42", "user-99", "system:job")Filter by operation
Section titled “Filter by operation”.operation(...ops) accepts "INSERT", "UPDATE", and "DELETE" (OR semantics):
// Only destructive eventsaudit.query().operation("DELETE")
// Mutations — inserts and updatesaudit.query().operation("INSERT", "UPDATE")Filter by severity
Section titled “Filter by severity”.severity(...levels) accepts "low", "medium", "high", "critical" (OR semantics):
// Flag for review — high and critical onlyaudit.query().severity("high", "critical")Entries without a severity are excluded when a severity filter is set.
Filter by compliance tag
Section titled “Filter by compliance tag”.compliance(...tags) uses AND semantics — entries must carry all listed tags:
// Must be tagged with both gdpr AND soc2audit.query().compliance("gdpr", "soc2")
// Any single tagaudit.query().compliance("pci")Filtering by time
Section titled “Filtering by time”.since() and .until() accept either a Date or a duration string. Duration strings are validated eagerly and resolved at query time so each call gets a fresh “now”:
| Unit | Example | Meaning |
|---|---|---|
h | "4h" | Last 4 hours |
d | "30d" | Last 30 days |
w | "2w" | Last 2 weeks |
m | "3m" | Last 3 months |
y | "1y" | Last 1 year |
// Relative: last 24 hoursaudit.query().since("24h")
// Absolute: from a specific dateaudit.query().since(new Date("2025-01-01"))
// Rangeaudit.query() .since("30d") .until(new Date("2025-06-01"))Full-text search
Section titled “Full-text search”.search(text) applies a full-text filter against entry fields. Maximum 500 characters. Adapters use parameterized queries — never raw string interpolation:
audit.query().search("profile updated")Pagination
Section titled “Pagination”Results are cursor-paginated. When nextCursor is present on the result, pass it to .after() for the next page:
// First pageconst page1 = await audit.query() .resource("users") .limit(50) .list();
// Next pageif (page1.nextCursor) { const page2 = await audit.query() .resource("users") .limit(50) .after(page1.nextCursor) .list();}.limit(n) must be greater than zero and cannot exceed maxQueryLimit (default 1000, configurable in betterAudit()). When no limit is set, maxQueryLimit is used as the default:
import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
// Raise the cap at configuration timeconst audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users"], maxQueryLimit: 5000,});Sort order
Section titled “Sort order”.order("asc" | "desc") controls the result order. When omitted, the adapter chooses its default (typically newest-first):
// Oldest first — useful for chronological reportsaudit.query().since("7d").order("asc").list()Combining filters
Section titled “Combining filters”All methods are chainable and each returns a new builder, so you can fork a base query and specialise it:
const base = audit.query() .since("30d") .severity("high", "critical");
// Two specialisations sharing the same time + severity filterconst deletions = await base.operation("DELETE").list();const userEvents = await base.resource("users").list();Exporting audit logs
Section titled “Exporting audit logs”audit.export() streams rows in cursor-paginated batches (default 500 per round-trip) so memory stays flat regardless of export size. It requires queryLogs on the database adapter — the same requirement as audit.query().
In-memory export
Section titled “In-memory export”Pass output: "string" to buffer the full output in memory. The result includes the string in data:
const result = await audit.export({ format: "csv", output: "string",});
console.log(result.rowCount); // number of rows exportedconsole.log(result.data); // full CSV stringFor JSON, choose between "ndjson" (one JSON object per line, default) and "array" (a single pretty-printed JSON array):
// NDJSON — stream-friendly, one object per lineconst ndjson = await audit.export({ format: "json", jsonStyle: "ndjson", output: "string",});
// JSON array — pretty-printed, easier to readconst jsonArray = await audit.export({ format: "json", jsonStyle: "array", output: "string",});Streaming to a WritableStream
Section titled “Streaming to a WritableStream”Pass a WritableStream<string> to write directly without buffering. The export engine applies backpressure per row.
The Web Streams WritableStream constructor is available natively in Node 22+, Bun, and Deno. Use it to wrap any sink:
import { createWriteStream } from "node:fs";
const fileStream = createWriteStream("audit-export.csv");const encoder = new TextEncoder();
const output = new WritableStream<string>({ write(chunk) { fileStream.write(encoder.encode(chunk)); }, close() { fileStream.end(); },});
await audit.export({ format: "csv", output });HTTP download endpoint
Section titled “HTTP download endpoint”audit.exportResponse() returns a standard Response with streaming body and correct headers — Content-Type, Content-Disposition (with auto-generated filename), and Cache-Control: no-cache. The export starts immediately; the response is returned before all rows are read.
import { Hono } from "hono";import { audit } from "../audit.js";
const app = new Hono();
// Ensure your auth middleware is mounted before this routeapp.get("/audit/export", (c) => { return audit.exportResponse({ format: "csv" });});import express from "express";import { audit } from "../audit.js";
const router = express.Router();
// Ensure your auth middleware is mounted before this routerouter.get("/audit/export", async (_req, res, next) => { try { const response = audit.exportResponse({ format: "csv" });
res.status(response.status); response.headers.forEach((value, key) => { res.setHeader(key, value); });
const body = response.body; if (body === null) { res.status(500).end(); return; }
// Drain the ReadableStream into the Express response const reader = body.getReader(); try { for (;;) { const { done, value } = await reader.read(); if (done) { break; } res.write(value); } } finally { reader.releaseLock(); } res.end(); } catch (err) { next(err); }});The default filename is audit-export-YYYY-MM-DD with the appropriate extension (.csv, .json, or .ndjson). Override with the filename option:
audit.exportResponse({ format: "csv", filename: "compliance-report-q1-2025",});// Content-Disposition: attachment; filename="compliance-report-q1-2025.csv"Filtering exports
Section titled “Filtering exports”Pass a query builder to either export method to filter what gets exported. The builder’s filters and sort order are respected; its limit is used as a total cap:
// Export only the last 90 days of critical user deletionsconst query = audit.query() .resource("users") .operation("DELETE") .severity("critical") .since("90d");
// In-memoryconst result = await audit.export({ format: "csv", query, output: "string",});
// As a download responseconst response = audit.exportResponse({ format: "csv", query,});Reference
Section titled “Reference”AuditQueryBuilder methods
Section titled “AuditQueryBuilder methods”| Method | Description |
|---|---|
.resource(table, recordId?) | Filter by table name and optional record ID. Last-write-wins. |
.actor(...ids) | Filter by actor IDs. OR semantics. Deduplicates. |
.operation(...ops) | Filter by operation type (INSERT, UPDATE, DELETE). OR semantics. |
.severity(...levels) | Filter by severity (low, medium, high, critical). OR semantics. |
.compliance(...tags) | Filter by compliance tags. AND semantics — entry must have all listed tags. |
.since(Date | string) | Filter entries created after this point. Duration strings accepted. |
.until(Date | string) | Filter entries created before this point. Duration strings accepted. |
.search(text) | Full-text search filter. Max 500 characters. |
.limit(n) | Max entries to return. Must be ≤ maxQueryLimit. |
.after(cursor) | Resume from a previous page using nextCursor. |
.order("asc" | "desc") | Result sort direction. Defaults to newest-first (desc) when omitted. |
.list() | Execute the query and return Promise<AuditQueryResult>. |
.toSpec() | Return the query spec without executing. Useful for tests. |
ExportOptions
Section titled “ExportOptions”| Option | Type | Default | Description |
|---|---|---|---|
format | "csv" | "json" | required | Output format. |
output | WritableStream<string> | "string" | required | Write target. |
query | AuditQueryBuilder | all entries | Filter and limit applied to the export. |
batchSize | number | 500 | Rows per database round-trip. |
jsonStyle | "ndjson" | "array" | "ndjson" | JSON output style. Ignored for CSV. |
csvDelimiter | string | "," | Single character CSV delimiter. |
ExportResponseOptions
Section titled “ExportResponseOptions”Same options as ExportOptions minus output, plus:
| Option | Type | Default | Description |
|---|---|---|---|
filename | string | "audit-export-YYYY-MM-DD" | Filename stem for Content-Disposition (no extension). |
ExportResult
Section titled “ExportResult”| Field | Type | Description |
|---|---|---|
rowCount | number | Total rows written. |
data | string | undefined | Full output string. Present only when output is "string". |
Next steps
Section titled “Next steps”- Enrichment — add labels, severity, and compliance tags to entries
- Configuration —
maxQueryLimit, retention policy, and lifecycle hooks - Adapters — ORM adapter setup and the
queryLogsrequirement