Recipes & 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 },
))
{"audit":{"action":"invoice.refund","actor":{"type":"user","id":"usr_42"},"target":{"type":"invoice","id":"inv_889"},"outcome":"success","version":1,"idempotencyKey":"ak_8f3c4b2a1e5d6f7c","prevHash":null,"hash":"3f2c8e1a..."}}
{"audit":{"action":"user.update","actor":{"type":"user","id":"usr_42"},"target":{"type":"user","id":"usr_99"},"outcome":"success","version":1,"idempotencyKey":"ak_5e7d8f9a0b1c2d3e","prevHash":"3f2c8e1a...","hash":"9a1b4d7c..."}}
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 }),
))
['audit']
| where audit.action == "invoice.refund"
| summarize count() by audit.outcome, bin(_time, 1h)
['audit']
| where audit.outcome == "denied"
| summarize count() by audit.actor.id, audit.action
| order by count_ desc
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 }))
SELECT id, timestamp, payload->'audit'->>'action' AS action,
payload->'audit'->>'outcome' AS outcome
FROM audit_events
WHERE id = 'ak_8f3c4b2a1e5d6f7c';
-- id | timestamp | action | outcome
-- ---------------------+-----------------------+-----------------+---------
-- ak_8f3c4b2a1e5d6f7c | 2026-04-24 10:23:45.6 | invoice.refund | success
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
| Symbol | Kind | Notes |
|---|---|---|
AuditFields | type | Reserved field on the wide event |
defineAuditAction(name, opts?) | factory | Typed action registry, infers target shape |
defineAuditCatalog(prefix, map) | factory | Bundle of typed audit actions sharing a prefix |
log.audit(fields) | method | Sugar over log.set({ audit }) + force-keep |
log.audit.deny(reason, fields) | method | Records a denied action |
audit(fields) | function | Standalone for scripts / jobs |
withAudit({ action, target })(fn) | wrapper | Auto-emit success / failure / denied |
auditDiff(before, after) | helper | Redact-aware JSON Patch for changes |
mockAudit() | test util | Capture + assert audits in tests |
auditEnricher(opts?) | enricher | Auto-fill request / runtime / tenant context |
auditOnly(drain, { await? }) | wrapper | Routes only events with an audit field |
signed(drain, opts) | wrapper | Generic integrity wrapper (hmac / hash-chain) |
auditRedactPreset | config | Strict PII for audit events |
Everything ships from the main evlog entrypoint.
Compliance
Integrity, redact presets, GDPR vs append-only, retention windows, and the most common pitfalls when shipping audit logs to production.
Enrichers
Add derived context to every wide event automatically — user agent, geo, request size, and trace context. Built-in enrichers from evlog/enrichers, plus how to compose them with your own.