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 |
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.
Lifecycle
Understand the full lifecycle of an evlog event, from creation to drain. Covers all three modes (simple logging, wide events, request logging), sampling, enrichment, and delivery.