Logging

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.

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.

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.

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
}
Naming convention for 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.
Don't fake the actor. Use 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' })
}

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',
})
Standalone 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

Why filter audits to a separate sink? Three reasons: cost (audit volume is tiny next to product telemetry — keep them separate so retention costs don't explode), permissions (the audit dataset should be read-only for engineers and write-only for the app), and retention (audits often live 7+ years; product logs rarely live more than 90 days).

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' — adds event.audit.signature (HMAC of the canonical event).
  • 'hash-chain' — adds event.audit.prevHash and event.audit.hash so the sequence forms a verifiable chain. Provide state: { load, save } for cross-process / durable chains (Redis, file, Postgres).
What 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',
}))
Don't feed entire DB rows into 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 paidrefunded), 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'] }),
})

Auto-Instrumentation with withAudit()

Devs forget to call log.audit(). Wrap the function and never miss a record:

When to wrap vs. call manually. Wrap functions that are pure audit-worthy actions (refund, delete, role change, password reset) — outcome resolution is automatic and you can't accidentally skip the call. Stick to manual 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,
})

Outcome resolution:

  • fn resolves → outcome: 'success'.
  • fn throws an AuditDeniedError (or any error with status === 403) → outcome: 'denied', error message becomes reason.
  • 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 },
)
A CLI to walk and verify the chain (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.
Rotate 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:

  1. Keep audit rows immutable.
  2. Encrypt PII fields with a per-actor key (held outside the audit store).
  3. 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() with log.audit.deny() on the negative branch of every authorisation check.
  • Leaking PII through changes. auditDiff() runs through your RedactConfig, but only if the field paths are listed. Add password, 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.id with the session id. actor.id is the stable user id (or system identity). Correlate sessions via context.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) or withAudit() 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 },
))

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 }),
))

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 }))

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

SymbolKindNotes
AuditFieldstypeReserved field on the wide event
defineAuditAction(name, opts?)factoryTyped action registry, infers target shape
log.audit(fields)methodSugar over log.set({ audit }) + force-keep
log.audit.deny(reason, fields)methodRecords a denied action
audit(fields)functionStandalone for scripts / jobs
withAudit({ action, target })(fn)wrapperAuto-emit success / failure / denied
auditDiff(before, after)helperRedact-aware JSON Patch for changes
mockAudit()test utilCapture + assert audits in tests
auditEnricher(opts?)enricherAuto-fill request / runtime / tenant context
auditOnly(drain, { await? })wrapperRoutes only events with an audit field
signed(drain, opts)wrapperGeneric integrity wrapper (hmac / hash-chain)
auditRedactPresetconfigStrict PII for audit events

Everything ships from the main evlog entrypoint.