Enrichment
ORM adapters capture raw audit data — table name, operation, before/after snapshots. Enrichment layers on human-readable meaning: labels, severity levels, compliance tags, and dynamic descriptions. You register rules once and they apply automatically to every matching entry, without touching your route handlers or service code.
audit.enrich() — the API
Section titled “audit.enrich() — the API”audit.enrich(table, operation, config)table— SQL table name, or"*"for all tables.operation—"INSERT","UPDATE","DELETE", or"*"for all operations.config— enrichment options (see sections below).
Three typical patterns:
import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users", "payments", "orders"],});
// Pattern 1: specific table + specific operationaudit.enrich("users", "DELETE", { label: "User account deleted", severity: "critical", compliance: ["gdpr"],});
// Pattern 2: specific table + all operationsaudit.enrich("payments", "*", { severity: "high", compliance: ["pci"],});
// Pattern 3: global default — all tables, all operationsaudit.enrich("*", "*", { severity: "low", compliance: ["soc2"],});You can call audit.enrich() as many times as you like. Rules for the same table/operation pair accumulate — they are merged according to the specificity rules below.
Human-readable labels
Section titled “Human-readable labels”The label field attaches a short, human-readable name to an audit entry. Without enrichment, entries only carry raw table names and operations:
// Without enrichment{ "tableName": "users", "operation": "DELETE", "recordId": "u_123" }
// With enrichment{ "tableName": "users", "operation": "DELETE", "recordId": "u_123", "label": "User account deleted" }Set a label per rule:
audit.enrich("users", "INSERT", { label: "New user registered" });audit.enrich("users", "UPDATE", { label: "User profile updated" });audit.enrich("users", "DELETE", { label: "User account deleted" });Precedence: if label is passed directly to captureLog(), it takes precedence over the registry label. The registry fills the gap when no per-call label is set.
Severity levels
Section titled “Severity levels”Four severity levels are available: "low", "medium", "high", "critical".
| Operation | Suggested severity |
|---|---|
INSERT | "low" |
UPDATE (non-sensitive) | "medium" |
DELETE | "high" |
DELETE on PII tables | "critical" |
| Bulk mutations | "high" or "critical" |
audit.enrich("*", "INSERT", { severity: "low" });audit.enrich("*", "UPDATE", { severity: "medium" });audit.enrich("*", "DELETE", { severity: "high" });
// Override for sensitive tablesaudit.enrich("users", "DELETE", { severity: "critical" });audit.enrich("payments", "*", { severity: "high" });Compliance tags
Section titled “Compliance tags”The compliance field attaches framework tags to entries. Common values: "gdpr", "soc2", "hipaa", "pci".
When multiple rules match, compliance arrays are concatenated and deduplicated — more specific rules add to, rather than replace, tags from less specific ones:
// Tier 1: global default — applies to every entryaudit.enrich("*", "*", { compliance: ["soc2"],});
// Tier 4: specific rule — adds "gdpr" for user deletesaudit.enrich("users", "DELETE", { compliance: ["gdpr"],});
// Result for users DELETE: ["soc2", "gdpr"]// Result for orders INSERT: ["soc2"]Tag values are freeform strings — use whatever your compliance team standardises on.
Dynamic descriptions
Section titled “Dynamic descriptions”The description option is a function called at write time with context about the mutation. Use it to generate richer, event-specific descriptions beyond what a static label provides:
audit.enrich("users", "UPDATE", { label: "User profile updated", description: ({ before, after, diff, actorId }) => { const fields = diff?.changedFields.join(", ") ?? "unknown fields"; return `Actor ${actorId ?? "unknown"} changed: ${fields}`; },});EnrichmentDescriptionContext
Section titled “EnrichmentDescriptionContext”| Field | Type | Description |
|---|---|---|
before | Record<string, unknown> | undefined | Pre-mutation row snapshot. |
after | Record<string, unknown> | undefined | Post-mutation row snapshot. |
diff | { changedFields: string[] } | undefined | Fields that changed between before and after. |
actorId | string | undefined | Actor from the current context. |
metadata | Record<string, unknown> | undefined | Merged metadata from the current context. |
Error handling: if the description function throws, the error is swallowed and description is left unset on the log entry. The entry is still written.
Notifications
Section titled “Notifications”The notify flag marks an entry for downstream notification integrations. Setting it to true does not trigger anything on its own — it is a signal for external systems (webhooks, alerting pipelines) that consume the audit log to act on the entry:
audit.enrich("users", "DELETE", { label: "User account deleted", severity: "critical", notify: true,});Like label and severity, notify is a scalar — more specific tiers overwrite less specific ones.
Custom metadata
Section titled “Custom metadata”There are two ways to attach metadata to audit entries.
Per-request metadata via context
Section titled “Per-request metadata via context”Use mergeAuditContext() to inject metadata into the current request scope. Every captureLog() call inside the callback inherits it:
import { Hono } from "hono";import { mergeAuditContext } from "@usebetterdev/audit";
const app = new Hono();
app.delete("/users/:id", async (c) => { await mergeAuditContext( { metadata: { requestId: c.req.header("x-request-id"), region: "eu-west-1" } }, async () => { await deleteUser(c.req.param("id")); }, ); return c.json({ ok: true });});import express from "express";import { mergeAuditContext } from "@usebetterdev/audit";
const router = express.Router();
router.delete("/users/:id", async (req, res, next) => { try { await mergeAuditContext( { metadata: { requestId: req.headers["x-request-id"], region: "eu-west-1" } }, async () => { await deleteUser(req.params.id); }, ); res.json({ ok: true }); } catch (error) { next(error); }});See the Actor Context guide for full details on context propagation.
Static metadata via beforeLog hooks
Section titled “Static metadata via beforeLog hooks”For metadata that is the same for every entry — environment, service name, version — use a beforeLog hook:
import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users"], beforeLog: [ (log) => { log.metadata = { ...log.metadata, environment: process.env.NODE_ENV, service: "api", }; }, ],});Specificity tiers and rule merging
Section titled “Specificity tiers and rule merging”When multiple rules match an event, they are resolved from least to most specific:
| Tier | Pattern | Matches |
|---|---|---|
| 1 (least specific) | "*", "*" | All tables, all operations |
| 2 | "*", "DELETE" | All tables, specific operation |
| 3 | "users", "*" | Specific table, all operations |
| 4 (most specific) | "users", "DELETE" | Specific table, specific operation |
Merge rules:
- Scalar fields (
label,severity,description,notify): last-write-wins — more specific tiers overwrite less specific ones. - Array fields (
compliance,redact,include): concatenated and deduplicated across all matching tiers.
// Tier 1audit.enrich("*", "*", { severity: "low", compliance: ["soc2"],});
// Tier 3audit.enrich("users", "*", { severity: "medium", // overwrites "low" compliance: ["internal"], // merged → ["soc2", "internal"]});
// Tier 4audit.enrich("users", "DELETE", { severity: "critical", // overwrites "medium" compliance: ["gdpr"], // merged → ["soc2", "internal", "gdpr"]});
// users DELETE resolves to: severity="critical", compliance=["soc2", "internal", "gdpr"]// users INSERT resolves to: severity="medium", compliance=["soc2", "internal"]// orders DELETE resolves to: severity="low", compliance=["soc2"]Field redaction
Section titled “Field redaction”Control which fields appear in beforeData and afterData snapshots. Two modes are available — use one or the other.
Blocklist (redact)
Section titled “Blocklist (redact)”Remove specific fields. Everything else is kept:
audit.enrich("users", "*", { redact: ["password", "ssn", "secret_key"],});// beforeData: { id: "1", name: "Alice", password: "hash" }// stored as: { id: "1", name: "Alice" }Allowlist (include)
Section titled “Allowlist (include)”Keep only the listed fields. Everything else is removed:
audit.enrich("users", "*", { include: ["id", "email", "name"],});// beforeData: { id: "1", name: "Alice", email: "[email protected]", password: "hash", ssn: "123" }// stored as: { id: "1", name: "Alice", email: "[email protected]" }When fields are redacted, the redactedFields column on the audit entry records which fields were removed. This is useful for compliance audits that need proof sensitive data was excluded.
Processing order
Section titled “Processing order”When a log entry is written, enrichment is applied in this order:
- Field redaction —
redact/includeapplied tobeforeData,afterData,diff.changedFields - Description function — called with post-redaction data; result stored in
description(only if not already set per-call) - Scalar and array fields —
label,severity,compliance,notifyapplied (only fill gaps; per-call values and context values take precedence) beforeLoghooks — run on the fully enriched log
Next steps
Section titled “Next steps”- Compliance Overview — map SOC 2, HIPAA, GDPR, and PCI DSS requirements to Better Audit features
- Configuration — full
betterAudit()options reference, retention, and lifecycle hooks - Actor Context — per-request metadata,
mergeAuditContext(), andaudit.withContext() - Adapters — middleware setup, actor extractors, and error handling