Recording Events
Five APIs cover every shape of audit recording: in-request, denied, standalone, auto-instrumented, and typed.
log.audit()
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 } })
This is the form you'll use most. The audit event lands on the same wide event as the rest of the request.
log.audit.deny()
log.audit.deny(reason, fields) records AuthZ-denied actions. Most teams forget to log denials, but they're 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"
}
}
}
Standalone audit()
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.defineAuditAction()
Define audit actions in one place to avoid magic strings and get full type-safety on target:
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',
}))
Pair this with the action dictionary from Schema → Action naming.
auditDiff()
For mutating actions, use auditDiff() to produce a compact, redact-aware JSON Patch:
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.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"
}
}
withAudit() — auto-instrumentation
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.