Compliance audit
The problem
You need to record every "this user did this thing" event in a way that satisfies a compliance auditor: typed actions, structured actor/target metadata, optionally tamper-evident, and shipped to a backend separate from your regular logs (so a leak of one doesn't compromise the other).
The full code
1. Define your audit catalog
import { defineAuditCatalog } from 'evlog'
export const billingAudit = defineAuditCatalog('billing', {
INVOICE_REFUND: { target: 'invoice' },
INVOICE_VOID: { target: 'invoice' },
SUBSCRIPTION_CANCEL: { target: 'subscription' },
PAYMENT_METHOD_REPLACE: { target: 'payment_method' },
} as const)
declare module 'evlog' {
interface RegisteredAuditCatalogs {
billing: typeof billingAudit
}
}
2. A custom drain for the audit-only sink
Audit events go to a separate ingest that has stricter access controls than the regular logs.
import { defineHttpDrain } from 'evlog/toolkit'
export function createAuditDrain(overrides?: { url?: string; token?: string }) {
return defineHttpDrain<{ url: string; token: string }>({
name: 'compliance-audit',
resolve: () => ({
url: overrides?.url ?? process.env.AUDIT_INGEST_URL!,
token: overrides?.token ?? process.env.AUDIT_INGEST_TOKEN!,
}),
encode: (events, config) => ({
url: `${config.url}/v1/audit-events`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.token}`,
},
// Filter: ship only events that carry an `audit` block
body: JSON.stringify(events.filter(e => e.audit)),
}),
})
}
3. (Optional) Wrap with the signed chain for tamper-evidence
import { signed } from 'evlog'
const drain = signed({
drain: createAuditDrain(),
secret: process.env.AUDIT_CHAIN_SECRET!,
})
Each event then carries a audit.chain.{prevHash, currentHash} — a missing or modified event breaks the chain and can be detected at audit time.
4. Wire it up
import { createAxiomDrain } from 'evlog/axiom'
import { createDrainPipeline } from 'evlog/pipeline'
const pipeline = createDrainPipeline()
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', pipeline(
createAxiomDrain(), // regular logs go here
createAuditDrain(), // audit-only events go here
))
})
5. Record audit actions in handlers
import { billingAudit } from '~/src/audit-catalog'
export default defineEventHandler(async (event) => {
const log = useLogger(event)
const body = await readBody(event)
await processRefund(body.invoiceId, body.amount)
log.audit(billingAudit.INVOICE_REFUND({
actor: { id: getUser(event).id, type: 'user' },
target: { id: body.invoiceId },
metadata: { amount: body.amount, reason: body.reason },
}))
return { ok: true }
})
What it gives you
- Typed audit actions —
billingAudit.INVOICE_REFUND(...)autocompletes parameters, the action key is fixed - Separate sink — audit events ship to a dedicated backend; regular logs flow normally to Axiom
- Optional tamper evidence — the signed chain catches tampering after the fact
- Standard wide events — auditors get the same context (user, request id, IP, …) every event has, plus the audit-specific block
Where to go next
- Audit overview — the full audit primitives reference
- Audit compliance — what specifically satisfies SOC2 / HIPAA-style requirements
- Catalogs as packages — share the catalog as
@my-org/evlog-billing-auditif multiple services emit billing actions - Custom drains — the building block used for the audit-only drain
Tenant-aware logging
Every wide event automatically carries the right tenant id, drawn from the request — no per-call-site setup, no risk of forgetting.
Cross-app error vocab
One `@my-org/evlog-errors` package every service depends on — same error codes, same `why` / `fix` strings, type-safe everywhere.