Audit Logs
First-class audit logs as a thin layer on top of evlog's wide events. Add tamper-evident audit trails to any app with one enricher, one drain wrapper, and one helper.

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/.

Terminal
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.

An audit event is a fact about an intent, not a measurement of an operation. A regular wide event answers "how did this request behave?" (latency, status, tokens). An audit event answers "who tried to do what, and was it allowed?". Same pipeline, different question — that's why the schema is reserved and the event is force-kept past sampling.
tail-sample gate· keep rate 10%
#1POST/api/checkout
random 0.42 < 0.10
dropped
#2GET/api/users
random 0.91 < 0.10
dropped
#3POST/api/refund
audit · force
kept
#4GET/api/me
random 0.07 < 0.10
kept
#5POST/api/login
audit · force
kept
#6PATCH/api/cart
random 0.55 < 0.10
dropped
#7DELETE/api/account
audit · force
kept
#8GET/api/health
random 0.83 < 0.10
dropped
audit kept0 / 3 (100%)
regular kept0 / ~10%
dropped0

Quickstart

You already use evlog. Add audit logs in three changes:

server/plugins/evlog.ts
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 }
})

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, and userAgent automatically via auditEnricher.
Why two drains? The main drain (Axiom, Datadog, ...) keeps audits next to the rest of your telemetry so dashboards and queries still work. The signed sink is your insurance: if the main drain has an outage, gets purged, or an admin quietly removes a row, the FS journal still holds the chain. Auditors want both — fast querying and a tamper-evident artefact.
drain pipeline· two drains, one event
active wide event
waiting for events…
main drain
createAxiomDrain()
0events ingested
audit drain
auditOnly(signed(fs))
0audits sealed
main drain · queryable audit drain · tamper-evident · long retention 0 non-audit events filtered out

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.

Where to next

Schema

The AuditFields type, action naming conventions, actor types, and idempotency.

Recording Events

log.audit, log.audit.deny, standalone audit(), withAudit, defineAuditAction, and auditDiff.

Drains & Integrity

auditEnricher, auditOnly, and signed (HMAC and hash-chain) drain wrappers.

Compliance

Integrity, redact presets, GDPR vs append-only, retention, and common pitfalls.

Recipes

FS, Axiom, and Postgres recipes — plus testing with mockAudit and the API reference.