Skip to content

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

src/audit.ts
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 operation
audit.enrich("users", "DELETE", {
label: "User account deleted",
severity: "critical",
compliance: ["gdpr"],
});
// Pattern 2: specific table + all operations
audit.enrich("payments", "*", {
severity: "high",
compliance: ["pci"],
});
// Pattern 3: global default — all tables, all operations
audit.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.

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.

Four severity levels are available: "low", "medium", "high", "critical".

OperationSuggested 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 tables
audit.enrich("users", "DELETE", { severity: "critical" });
audit.enrich("payments", "*", { severity: "high" });

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 entry
audit.enrich("*", "*", {
compliance: ["soc2"],
});
// Tier 4: specific rule — adds "gdpr" for user deletes
audit.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.

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}`;
},
});
FieldTypeDescription
beforeRecord<string, unknown> | undefinedPre-mutation row snapshot.
afterRecord<string, unknown> | undefinedPost-mutation row snapshot.
diff{ changedFields: string[] } | undefinedFields that changed between before and after.
actorIdstring | undefinedActor from the current context.
metadataRecord<string, unknown> | undefinedMerged 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.

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.

There are two ways to attach metadata to audit entries.

Use mergeAuditContext() to inject metadata into the current request scope. Every captureLog() call inside the callback inherits it:

src/routes/users.ts
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 });
});

See the Actor Context guide for full details on context propagation.

For metadata that is the same for every entry — environment, service name, version — use a beforeLog hook:

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

When multiple rules match an event, they are resolved from least to most specific:

TierPatternMatches
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 1
audit.enrich("*", "*", {
severity: "low",
compliance: ["soc2"],
});
// Tier 3
audit.enrich("users", "*", {
severity: "medium", // overwrites "low"
compliance: ["internal"], // merged → ["soc2", "internal"]
});
// Tier 4
audit.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"]

Control which fields appear in beforeData and afterData snapshots. Two modes are available — use one or the other.

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

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.

When a log entry is written, enrichment is applied in this order:

  1. Field redactionredact / include applied to beforeData, afterData, diff.changedFields
  2. Description function — called with post-redaction data; result stored in description (only if not already set per-call)
  3. Scalar and array fieldslabel, severity, compliance, notify applied (only fill gaps; per-call values and context values take precedence)
  4. beforeLog hooks — run on the fully enriched log
  • 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(), and audit.withContext()
  • Adapters — middleware setup, actor extractors, and error handling