Audit Logs

Recording Events

log.audit, log.audit.deny, standalone audit(), withAudit auto-instrumentation, defineAuditAction 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.

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.