Audit Logs

Recording Events

log.audit, log.audit.deny, standalone audit(), withAudit auto-instrumentation, defineAuditAction and defineAuditCatalog registries, and auditDiff change patches.

Five APIs cover every shape of audit recording: in-request, denied, standalone, auto-instrumented, and typed.

log.audit()

log.audit() is sugar over log.set({ audit: ... }) plus tail-sample force-keep:

log.audit({
  action: 'invoice.refund',
  actor: { type: 'user', id: user.id },
  target: { type: 'invoice', id: 'inv_889' },
  outcome: 'success',
})

// Strictly equivalent to:
log.set({ audit: { action: 'invoice.refund', /* ... */, version: 1 } })

This is the form you'll use most. The audit event lands on the same wide event as the rest of the request.

log.audit.deny()

log.audit.deny(reason, fields) records AuthZ-denied actions. Most teams forget to log denials, but they're exactly what auditors and security teams ask for:

if (!user.canRefund(invoice)) {
  log.audit.deny('Insufficient permissions', {
    action: 'invoice.refund',
    actor: { type: 'user', id: user.id },
    target: { type: 'invoice', id: invoice.id },
  })
  throw createError({ status: 403, message: 'Forbidden' })
}

Standalone audit()

For non-request contexts (jobs, scripts, CLIs), use the standalone audit():

import { audit } from 'evlog'

audit({
  action: 'cron.cleanup',
  actor: { type: 'system', id: 'cron' },
  target: { type: 'job', id: 'cleanup-stale-sessions' },
  outcome: 'success',
})
Standalone audit() events have no requestId, no context.ip, no userAgent — there is no request to enrich from. Add your own context manually (context: { jobId, queue, runId }) when it matters for forensics.

defineAuditAction()

Define audit actions in one place to avoid magic strings and get full type-safety on target:

import { defineAuditAction } from 'evlog'

const refund = defineAuditAction('invoice.refund', { target: 'invoice' })

log.audit(refund({
  actor: { type: 'user', id: user.id },
  target: { id: 'inv_889' }, // type inferred as 'invoice'
  outcome: 'success',
}))

Pair this with the action dictionary from Schema → Action naming.

defineAuditCatalog()

For more than a handful of actions, group them in a typed catalog instead of declaring defineAuditAction one-by-one. Same convention as error catalogs: UPPER_SNAKE_CASE keys, lower.dot.case prefix, wire action is ${prefix}.${KEY}.

import { defineAuditCatalog } from 'evlog'

export const billingAudit = defineAuditCatalog('billing', {
  INVOICE_REFUND:      { target: 'invoice' },
  INVOICE_CREATE:      { target: 'invoice' },
  INVOICE_VOID:        { target: 'invoice' },
  SUBSCRIPTION_CANCEL: { target: 'subscription' },
})

Each entry produces a thin wrapper around defineAuditAction (target type is fixed at definition time, action name is auto-prefixed). Catalog metadata is exposed on _actions and _prefix:

billingAudit.INVOICE_REFUND.action // 'billing.INVOICE_REFUND' (literal type)
billingAudit.INVOICE_REFUND.target // 'invoice'
billingAudit._actions              // readonly ['billing.INVOICE_REFUND', ...]

defineAuditAction vs defineAuditCatalog — when to choose

Both produce the same call-site factory shape. Pick by scale:

  • defineAuditAction(action, opts?) — one-off actions, or per-file organisation in very large repos. Mirrors defineError. Equivalent to a catalog with a single entry but with no prefix derivation: you write the full wire action directly.
  • defineAuditCatalog(prefix, map) — group anything beyond a handful of related actions under one prefix. Mirrors defineErrorCatalog. The wire action is auto-derived as ${prefix}.${KEY}, catalog metadata (_actions, _prefix) is exposed for introspection, and a single declare module 'evlog' line surfaces the whole bundle in the typed AuditAction union.

You can mix the two in the same codebase — keep cross-cutting one-off actions as defineAuditAction, group bounded contexts (billing, auth, subscription) as catalogs.

Type-safe actions everywhere (opt-in)

Mirror the error catalog augmentation by augmenting RegisteredAuditCatalogs:

import type { billingAudit } from './audit/billing'

declare module 'evlog' {
  interface RegisteredAuditCatalogs {
    billing: typeof billingAudit
  }
}

This surfaces the union of all registered actions on the typed AuditAction export, useful for shared helpers, dashboards, and refactor-safe comparisons.

Going further. The dedicated Catalogs page covers the scaling story (single file → folder → feature → npm package) for both error and audit catalogs, plus npm packaging, composition patterns, and the type-augmentation deep dive.

auditDiff()

For mutating actions, use auditDiff() to produce a compact, redact-aware JSON Patch:

Don't feed entire DB rows into auditDiff(). Strip computed columns, hashed passwords, internal flags, and large JSON blobs before diffing. The point of changes is what changed semantically (status went from paidrefunded), not what bytes changed (a lastModified timestamp ticked). A noisy changes field is the fastest way to make audit logs unreadable.
import { auditDiff } from 'evlog'

const before = await db.users.byId(id)
const after = await db.users.update(id, patch)

log.audit({
  action: 'user.update',
  actor: { type: 'user', id: actorId },
  target: { type: 'user', id },
  outcome: 'success',
  changes: auditDiff(before, after, { redactPaths: ['password', 'token'] }),
})

withAudit() — auto-instrumentation

Devs forget to call log.audit(). Wrap the function and never miss a record:

When to wrap vs. call manually. Wrap functions that are pure audit-worthy actions (refund, delete, role change, password reset) — outcome resolution is automatic and you can't accidentally skip the call. Stick to manual log.audit() when the audit is one of several decisions inside a larger handler, or when you need to emit the audit before the action completes (e.g. "user requested deletion").
import { withAudit, AuditDeniedError } from 'evlog'

const refundInvoice = withAudit(
  { action: 'invoice.refund', target: input => ({ type: 'invoice', id: input.id }) },
  async (input: { id: string }, ctx) => {
    if (!ctx.actor) throw new AuditDeniedError('Anonymous refund denied')
    return await db.invoices.refund(input.id)
  },
)

await refundInvoice({ id: 'inv_889' }, {
  actor: { type: 'user', id: user.id },
  correlationId: requestId,
})

Outcome resolution:

  • fn resolves → outcome: 'success'.
  • fn throws an AuditDeniedError (or any error with status === 403) → outcome: 'denied', error message becomes reason.
  • Other thrown errors → outcome: 'failure', then re-thrown.