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.
Add an audit log to my app
Agent Skills
Install the evlog skill catalog so your assistant can follow build-audit-logs end to end: written policy, framework wiring, withAudit / log.audit, denials, redaction, multi-tenant isolation, tamper-evident sinks, and grep-based review passes. If you use the file-system drain for audits or general logs, analyze-logs teaches assistants to read NDJSON under .evlog/logs/.
npx skills add https://www.evlog.dev
See Agent Skills for the full list. Skill paths in the repo: skills/build-audit-logs, skills/analyze-logs.
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 { 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 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 { 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.
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.