Audit Logs
evlog's audit layer is not a parallel system. Audit events are wide events with a reserved audit field. Every existing primitive — drains, enrichers, redact, tail-sampling — applies as is. Enable audit logs by adding 1 enricher + 1 drain wrapper + 1 helper.
Why Audit Logs?
Compliance frameworks (SOC2, HIPAA, GDPR, PCI) require knowing who did what, on which resource, when, from where, with which outcome. evlog covers this without a second logging library.
Quickstart
You already use evlog. Add audit logs in three changes:
import { auditEnricher, auditOnly, signed } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
import { createFsDrain } from 'evlog/fs'
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('evlog:enrich', auditEnricher())
nitro.hooks.hook('evlog:drain', createAxiomDrain())
nitro.hooks.hook('evlog:drain', auditOnly(
signed(createFsDrain({ dir: '.audit' }), { strategy: 'hash-chain' }),
{ await: true },
))
})
export default defineEventHandler(async (event) => {
const log = useLogger(event)
const user = await requireUser(event)
const invoice = await refundInvoice(getRouterParam(event, 'id'))
log.audit({
action: 'invoice.refund',
actor: { type: 'user', id: user.id, email: user.email },
target: { type: 'invoice', id: invoice.id },
outcome: 'success',
reason: 'Customer requested refund',
})
return { ok: true }
})
import type { EvlogVariables } from 'evlog/hono'
import { Hono } from 'hono'
const app = new Hono<EvlogVariables>()
app.post('/invoices/:id/refund', async (c) => {
const log = c.get('log')
const user = await requireUser(c)
const invoice = await refundInvoice(c.req.param('id'))
log.audit({
action: 'invoice.refund',
actor: { type: 'user', id: user.id, email: user.email },
target: { type: 'invoice', id: invoice.id },
outcome: 'success',
reason: 'Customer requested refund',
})
return c.json({ ok: true })
})
import type { Request, Response } from 'express'
app.post('/invoices/:id/refund', async (req: Request, res: Response) => {
const log = req.log
const user = await requireUser(req)
const invoice = await refundInvoice(req.params.id)
log.audit({
action: 'invoice.refund',
actor: { type: 'user', id: user.id, email: user.email },
target: { type: 'invoice', id: invoice.id },
outcome: 'success',
reason: 'Customer requested refund',
})
res.json({ ok: true })
})
import { withEvlog, useLogger } from '@/lib/evlog'
export const POST = withEvlog(async (req, { params }) => {
const log = useLogger()
const user = await requireUser(req)
const invoice = await refundInvoice(params.id)
log.audit({
action: 'invoice.refund',
actor: { type: 'user', id: user.id, email: user.email },
target: { type: 'invoice', id: invoice.id },
outcome: 'success',
reason: 'Customer requested refund',
})
return Response.json({ ok: true })
})
import { audit } from 'evlog'
audit({
action: 'invoice.refund',
actor: { type: 'system', id: 'billing-worker' },
target: { type: 'invoice', id: 'inv_889' },
outcome: 'success',
reason: 'Auto-refund triggered by chargeback webhook',
})
{
"level": "info",
"service": "billing-api",
"method": "POST",
"path": "/api/invoices/inv_889/refund",
"status": 200,
"duration": "84ms",
"requestId": "a566ef91-7765-4f59-b6f0-b9f40ce71599",
"audit": {
"action": "invoice.refund",
"actor": { "type": "user", "id": "usr_42", "email": "demo@example.com" },
"target": { "type": "invoice", "id": "inv_889" },
"outcome": "success",
"reason": "Customer requested refund",
"version": 1,
"idempotencyKey": "ak_8f3c4b2a1e5d6f7c",
"context": {
"requestId": "a566ef91-7765-4f59-b6f0-b9f40ce71599",
"ip": "203.0.113.7",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
}
}
}
That's it. The audit event:
- Travels through the same wide-event pipeline as the rest of your logs.
- Is always kept past tail sampling.
- Goes to your main drain (Axiom) and to a dedicated, signed, append-only sink (FS journal).
- Carries
requestId,traceId,ip, anduserAgentautomatically viaauditEnricher.
The Audit Schema
event.audit is a typed field on every wide event. Downstream queries filter on audit IS NOT NULL.
interface AuditFields {
action: string // 'invoice.refund'
actor: {
type: 'user' | 'system' | 'api' | 'agent'
id: string
displayName?: string
email?: string
// For type === 'agent', mirrors evlog/ai fields:
model?: string
tools?: string[]
reason?: string
promptId?: string
}
target?: { type: string, id: string, [k: string]: unknown }
outcome: 'success' | 'failure' | 'denied'
reason?: string
changes?: { before?: unknown, after?: unknown }
causationId?: string // ID of the action that caused this one
correlationId?: string // Shared by every action in one operation
version?: number // Defaults to 1
idempotencyKey?: string // Auto-derived; safe retries across drains
context?: { // Filled by auditEnricher
requestId?: string
traceId?: string
ip?: string
userAgent?: string
tenantId?: string
}
signature?: string // Set by signed({ strategy: 'hmac' })
prevHash?: string // Set by signed({ strategy: 'hash-chain' })
hash?: string
}
action. Use noun.verb (invoice.refund, user.invite, apiKey.revoke). Past tense if the action already happened (invoice.refunded), present tense if withAudit() will resolve the outcome. Keep a small fixed dictionary in one file — auditors and SIEM rules query on action, so a typo is a missing alert.actor.type: 'system' for cron jobs, queue workers, and background tasks; actor.type: 'api' for machine-to-machine calls authenticated by a token; actor.type: 'agent' for AI tool calls. Logging a synthetic 'user' for system actions is the single fastest way to fail an audit review.Composition
Each layer is opt-in and replaceable. Visually, the path of an audit event through your pipeline looks like this:
log.audit / audit / withAudit
│
▼
set event.audit
│
▼
force-keep tail-sample
│
▼
auditEnricher
│
▼
redact + auditRedactPreset
│
┌──────────┴──────────┐
▼ ▼
main drain auditOnly(
(Axiom / signed(
Datadog / fsDrain))
...)
Every node except log.audit, auditEnricher, and auditOnly/signed is shared with regular wide events.
Helper
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 } })
log.audit.deny(reason, fields) records AuthZ-denied actions — most teams forget to log denials, but they are 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' })
}
{
"level": "warn",
"service": "billing-api",
"method": "POST",
"path": "/api/invoices/inv_889/refund",
"status": 403,
"duration": "12ms",
"requestId": "9c3f7d12-8a45-4e60-b8a9-1f0d4c5e6e7d",
"audit": {
"action": "invoice.refund",
"actor": { "type": "user", "id": "usr_intruder" },
"target": { "type": "invoice", "id": "inv_889" },
"outcome": "denied",
"reason": "Insufficient permissions",
"version": 1,
"idempotencyKey": "ak_d12c3a4f5b6e7d8c",
"context": {
"requestId": "9c3f7d12-8a45-4e60-b8a9-1f0d4c5e6e7d",
"ip": "203.0.113.7"
}
}
}
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',
})
{
"level": "info",
"service": "billing-api",
"audit": {
"action": "cron.cleanup",
"actor": { "type": "system", "id": "cron" },
"target": { "type": "job", "id": "cleanup-stale-sessions" },
"outcome": "success",
"version": 1,
"idempotencyKey": "ak_2b8e1f9d4c6a7b3e"
}
}
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.Enricher
auditEnricher() populates event.audit.context.{requestId, traceId, ip, userAgent, tenantId}. Skip it and ship a custom enricher if your strategy differs.
nitro.hooks.hook('evlog:enrich', auditEnricher({
tenantId: ctx => ctx.event.tenant as string | undefined,
bridge: { getSession: async ctx => readSessionActor(ctx.headers) },
}))
Drain Wrappers
auditOnly(drain) only forwards events with an audit field. Compose with any drain:
import { auditOnly } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
// Send audits to a dedicated Axiom dataset:
nitro.hooks.hook('evlog:drain', auditOnly(
createAxiomDrain({ dataset: 'audit', token: process.env.AXIOM_AUDIT_TOKEN }),
))
Set await: true to make audit writes synchronous (no fire-and-forget for audits — crash-safe by default):
auditOnly(createFsDrain({ dir: '.audit' }), { await: true })
signed(drain, opts) adds tamper-evident integrity. Strategies:
'hmac'— addsevent.audit.signature(HMAC of the canonical event).'hash-chain'— addsevent.audit.prevHashandevent.audit.hashso the sequence forms a verifiable chain. Providestate: { load, save }for cross-process / durable chains (Redis, file, Postgres).
signed() actually buys you. Detection, not prevention. Anyone with write access to the underlying sink can still nuke the file or table — but the chain proves which events were dropped or modified after the fact. Skip signed() if you already write to an append-only / WORM store (S3 Object Lock, Postgres with row-level immutability, BigQuery append-only tables); doubling integrity layers just adds latency without raising the bar.import { signed } from 'evlog'
signed(drain, { strategy: 'hmac', secret: process.env.AUDIT_SECRET! })
signed(drain, {
strategy: 'hash-chain',
state: {
load: () => fs.readFile('.audit/head', 'utf8').catch(() => null),
save: (h) => fs.writeFile('.audit/head', h),
},
})
Schema Discipline
Define audit actions in one place to avoid magic strings:
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',
}))
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 paid → refunded), not what bytes changed (a lastModified timestamp ticked). A noisy changes field is the fastest way to make audit logs unreadable.For mutating actions, use auditDiff() to produce a compact, redact-aware JSON Patch:
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'] }),
})
{
"audit": {
"action": "user.update",
"actor": { "type": "user", "id": "usr_42" },
"target": { "type": "user", "id": "usr_99" },
"outcome": "success",
"changes": [
{ "op": "replace", "path": "/email", "from": "old@example.com", "to": "new@example.com" },
{ "op": "replace", "path": "/role", "from": "member", "to": "admin" },
{ "op": "replace", "path": "/password", "from": "[REDACTED]", "to": "[REDACTED]" }
],
"version": 1,
"idempotencyKey": "ak_5e7d8f9a0b1c2d3e"
}
}
Auto-Instrumentation with withAudit()
Devs forget to call log.audit(). Wrap the function and never miss a record:
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,
})
{
"audit": {
"action": "invoice.refund",
"actor": { "type": "user", "id": "usr_42" },
"target": { "type": "invoice", "id": "inv_889" },
"outcome": "success",
"version": 1,
"idempotencyKey": "ak_8f3c4b2a1e5d6f7c",
"correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599"
}
}
{
"level": "error",
"audit": {
"action": "invoice.refund",
"actor": { "type": "user", "id": "usr_42" },
"target": { "type": "invoice", "id": "inv_889" },
"outcome": "failure",
"reason": "Stripe error: charge already refunded",
"version": 1,
"idempotencyKey": "ak_4c5d6e7f8a9b0c1d",
"correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599"
},
"error": {
"name": "StripeError",
"message": "charge already refunded",
"stack": "..."
}
}
{
"level": "warn",
"audit": {
"action": "invoice.refund",
"actor": { "type": "system", "id": "anonymous" },
"target": { "type": "invoice", "id": "inv_889" },
"outcome": "denied",
"reason": "Anonymous refund denied",
"version": 1,
"idempotencyKey": "ak_d12c3a4f5b6e7d8c",
"correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599"
}
}
Outcome resolution:
fnresolves →outcome: 'success'.fnthrows anAuditDeniedError(or any error withstatus === 403) →outcome: 'denied', error message becomesreason.- Other thrown errors →
outcome: 'failure', then re-thrown.
Compliance
Integrity
Hash-chain the audit log so any tampering is detectable. Each event's hash includes the previous hash, so deleting a row breaks the chain forward of that point.
auditOnly(
signed(createFsDrain({ dir: '.audit' }), { strategy: 'hash-chain' }),
{ await: true },
)
evlog audit verify) is on the roadmap. Until then, validate by recomputing the hashes of stored events and comparing each prevHash against the previous event's hash.secret for HMAC-signed audits annually. When you rotate, embed a key id alongside the signature (e.g. extend AuditFields with keyId via declare module) so old events stay verifiable against the previous secret. Verifiers should look up the key by id, not assume a single global secret.Redact
Audit events run through your existing RedactConfig. Compose with the strict audit preset to harden PII handling:
import { auditRedactPreset } from 'evlog'
initLogger({
redact: {
paths: [
...(auditRedactPreset.paths ?? []),
'user.password',
],
},
})
The preset drops Authorization / Cookie headers and common credential field names (password, token, apiKey, cardNumber, cvv, ssn) wherever they appear inside audit.changes.before and audit.changes.after.
GDPR vs Append-Only
Append-only audit logs collide with GDPR's right to be forgotten. Recommended pattern today:
- Keep audit rows immutable.
- Encrypt PII fields with a per-actor key (held outside the audit store).
- To "forget" a user, delete their key — the audit row stays, the chain stays valid, the PII becomes unreadable.
A built-in cryptoShredding helper is on the follow-up roadmap.
Retention
Retention is a storage-layer concern by design. evlog's audit layer doesn't enforce retention windows because every supported sink already has a stronger, audited mechanism for it. Pick the one matching your sink:
- FS — combine
createFsDrain({ maxFiles })with a daily compactor. - Postgres — schedule
DELETE FROM audit_events WHERE timestamp < now() - interval '7 years'. - Axiom / Datadog / Loki — set the dataset retention policy in the platform.
Document the chosen window in your security policy. Auditors care about the written rule, not the enforcing component.
Common Pitfalls
- Logging only successes. Auditors care most about denials. Always pair
log.audit()withlog.audit.deny()on the negative branch of every authorisation check. - Leaking PII through
changes.auditDiff()runs through yourRedactConfig, but only if the field paths are listed. Addpassword,token,apiKey, etc. once globally so you never have to think about it again. - Treating audits as observability. Don't sample, downsample, or summarise audit events. Force-keep is on by default — don't disable it.
- Conflating
actor.idwith the session id.actor.idis the stable user id (or system identity). Correlate sessions viacontext.requestId/context.traceId, never via the actor. - Forgetting standalone jobs. Cron tasks, queue workers, and CLIs trigger audit-worthy actions too. Use
audit()(no request) orwithAudit()to keep coverage parity with your HTTP routes.
Recipes
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
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()
})
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.
Better Auth Integration
Automatically identify users on every request. Every wide event includes who made the request — userId, user profile, and session metadata — with zero manual work.
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.