Audit Logs

Recipes & Reference

File system, Axiom, and Postgres recipes for audit logs, plus mockAudit for tests and the full API reference.

Pick the recipe that matches your sink, drop it in, and you have a tamper-evident audit log. Each recipe composes the same primitives (auditOnly, signed, optional await: true) over different drains.

Audit logs on disk

import { auditOnly, signed } from 'evlog'
import { createFsDrain } from 'evlog/fs'

nitro.hooks.hook('evlog:drain', auditOnly(
  signed(createFsDrain({ dir: '.audit', maxFiles: 30 }), { strategy: 'hash-chain' }),
  { await: true },
))

Each line's prevHash matches the previous line's hash. Tampering with any row breaks the chain forward of that point — a verifier replays the hashes and reports the first mismatch.

Audit logs to a dedicated Axiom dataset

import { auditOnly } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'

nitro.hooks.hook('evlog:drain', createAxiomDrain({ dataset: 'logs' }))
nitro.hooks.hook('evlog:drain', auditOnly(
  createAxiomDrain({ dataset: 'audit', token: process.env.AXIOM_AUDIT_TOKEN }),
))

Splitting datasets means the audit dataset can have a longer retention (7y), tighter access controls, and a separate billing line — without touching the rest of your pipeline.

Audit logs in Postgres

import { auditOnly } from 'evlog'
import type { DrainContext } from 'evlog'

const postgresAudit = async (ctx: DrainContext) => {
  await db.insert(auditEvents).values({
    id: ctx.event.audit!.idempotencyKey,
    timestamp: new Date(ctx.event.timestamp),
    payload: ctx.event,
  }).onConflictDoNothing()
}

nitro.hooks.hook('evlog:drain', auditOnly(postgresAudit, { await: true }))

The deterministic idempotencyKey makes retries safe — duplicate inserts collapse via ON CONFLICT DO NOTHING. Without it, a transient network blip during a retry would create a duplicate audit row, which is exactly what you don't want.

Testing audits

mockAudit() captures every audit event emitted during a test:

import { mockAudit } from 'evlog'

it('refunds the invoice and records an audit', async () => {
  const captured = mockAudit()

  await refundInvoice({ id: 'inv_889' }, { actor: { type: 'user', id: 'u1' } })

  expect(captured.events).toHaveLength(1)
  expect(captured.toIncludeAuditOf({
    action: 'invoice.refund',
    target: { type: 'invoice', id: 'inv_889' },
    outcome: 'success',
  })).toBe(true)

  captured.restore()
})

Always call captured.restore() in an afterEach (or wrap with a fixture) so a failing assertion never leaks into the next test.

API Reference

SymbolKindNotes
AuditFieldstypeReserved field on the wide event
defineAuditAction(name, opts?)factoryTyped action registry, infers target shape
log.audit(fields)methodSugar over log.set({ audit }) + force-keep
log.audit.deny(reason, fields)methodRecords a denied action
audit(fields)functionStandalone for scripts / jobs
withAudit({ action, target })(fn)wrapperAuto-emit success / failure / denied
auditDiff(before, after)helperRedact-aware JSON Patch for changes
mockAudit()test utilCapture + assert audits in tests
auditEnricher(opts?)enricherAuto-fill request / runtime / tenant context
auditOnly(drain, { await? })wrapperRoutes only events with an audit field
signed(drain, opts)wrapperGeneric integrity wrapper (hmac / hash-chain)
auditRedactPresetconfigStrict PII for audit events

Everything ships from the main evlog entrypoint.