---
title: "Audit Logs"
description: "Audit logs as a thin layer over wide events. A tamper-evident trail from one enricher, one drain wrapper, and one helper."
canonical_url: "https://www.evlog.dev/use-cases/audit/overview"
---
# Audit Logs

> Audit logs as a thin layer over wide events. A tamper-evident trail from 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**.

<prompt :actions="["copy","cursor","claude"]" description="Add an audit log to my app" icon="i-lucide-shield-check">

Add a tamper-evident audit log to my app on top of evlog.

- Identify my framework and follow its evlog integration pattern
- Register auditEnricher() on the evlog:enrich hook (or in initLogger.enrichers)
- Register a separate auditOnly(signed(createFsDrain({ dir: '.audit' }), { strategy: 'hash-chain' })) drain alongside my main drain
- Use { await: true } on the audit drain so audit events are flushed before the response returns
- Call log.audit({ action, actor, target, outcome, reason }) for every security-sensitive action (login, role change, refund, data export, deletion)
- Audit events are force-kept past sampling and signed via hash-chain for tamper-evidence
- Combine with the Better Auth integration so actor.id / actor.email are automatic

Docs: [https://www.evlog.dev/use-cases/audit/overview](https://www.evlog.dev/use-cases/audit/overview)
Adapters: [https://www.evlog.dev/integrate/adapters/overview](https://www.evlog.dev/integrate/adapters/overview)

</prompt>

## 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 drains, 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/`.

```bash [Terminal]
npx skills add https://www.evlog.dev
```

See [Agent Skills](https://www.evlog.dev/reference/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.

<tip>

**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, which is why the schema is reserved and the event is force-kept past sampling.

</tip>

<audit-force-keep>



</audit-force-keep>

## Quickstart

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

```typescript [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 },
  ))
})
```

<framework-tabs>

```typescript [Nuxt]
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 }
})
```

```typescript [Nitro]
import { defineHandler } from 'nitro/h3'
import { useLogger } from 'evlog/nitro/v3'
export default defineHandler(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 }
})
```

```typescript [Next.js]
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 })
})
```

```typescript [Hono]src/index.ts
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 })
})
```

```typescript [Express]src/index.ts
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 })
})
```

```typescript [Standalone]scripts/refund-job.ts
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',
})
```

</framework-tabs>

```json [Output — wide event]
{
  "level": "info",
  "service": "billing-api",
  "method": "POST",
  "path": "/api/invoices/inv_889/refund",
  "status": 200,
  "duration": "84ms",
  "durationMs": 84,
  "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 drain (FS journal).
- Carries `requestId`, `traceId`, `ip`, and `userAgent` automatically via `auditEnricher`.

<tip>

**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 drain 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.

</tip>

<audit-dual-sink>



</audit-dual-sink>

## Composition

Each layer is **opt-in and replaceable**. Every node except `log.audit`, `auditEnricher`, and `auditOnly` / `signed` is shared with regular wide events.

<audit-composition-flow>



</audit-composition-flow>

## Where to next

<card-group>
<card icon="i-lucide-file-text" title="Schema" to="https://www.evlog.dev/use-cases/audit/schema">

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

</card>

<card icon="i-lucide-pen-line" title="Recording Events" to="https://www.evlog.dev/use-cases/audit/recording">

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

</card>

<card icon="i-lucide-link" title="Drains & Integrity" to="https://www.evlog.dev/use-cases/audit/pipeline">

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

</card>

<card icon="i-lucide-shield-check" title="Compliance" to="https://www.evlog.dev/use-cases/audit/compliance">

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

</card>

<card icon="i-lucide-book-open" title="Recipes" to="https://www.evlog.dev/use-cases/audit/recipes">

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

</card>
</card-group>

---

- [Schema](https://www.evlog.dev/use-cases/audit/schema)
- [Recording](https://www.evlog.dev/use-cases/audit/recording)
- [Drains & Integrity](https://www.evlog.dev/use-cases/audit/pipeline)
- [Compliance](https://www.evlog.dev/use-cases/audit/compliance)
- [Recipes](https://www.evlog.dev/use-cases/audit/recipes)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
