---
title: "Recipes & Reference"
description: "File system, Axiom, and Postgres recipes for audit logs, plus mockAudit for tests and the full API reference."
canonical_url: "https://www.evlog.dev/use-cases/audit/recipes"
---
# Recipes & Reference

> File system, Axiom, and Postgres recipes for audit logs, plus mockAudit for tests and the full API reference.

Pick the recipe that matches your drain, drop it in, and you have a tamper-evident audit log. Each recipe composes the same primitives (`auditOnly`, `signed`, optional `await: true`) over different drains.

## Audit logs on disk

<code-group>

```typescript [Input — server/plugins/evlog.ts]
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 },
))
```

```jsonl [Output — .audit/2026-04-24.ndjson]
{"audit":{"action":"invoice.refund","actor":{"type":"user","id":"usr_42"},"target":{"type":"invoice","id":"inv_889"},"outcome":"success","version":1,"idempotencyKey":"ak_8f3c4b2a1e5d6f7c","prevHash":null,"hash":"3f2c8e1a..."}}
{"audit":{"action":"user.update","actor":{"type":"user","id":"usr_42"},"target":{"type":"user","id":"usr_99"},"outcome":"success","version":1,"idempotencyKey":"ak_5e7d8f9a0b1c2d3e","prevHash":"3f2c8e1a...","hash":"9a1b4d7c..."}}
```

</code-group>

Each line's `prevHash` matches the previous line's `hash`. Tampering with any row breaks the chain forward of that point, and a verifier replays the hashes and reports the first mismatch.

## Audit logs to a dedicated Axiom dataset

<code-group>

```typescript [Input — server/plugins/evlog.ts]
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', apiKey: process.env.AXIOM_AUDIT_API_KEY }),
))
```

```kusto [Output — Axiom query]
['audit']
| where audit.action == "invoice.refund"
| summarize count() by audit.outcome, bin(_time, 1h)
```

```kusto [Output — denials by actor]
['audit']
| where audit.outcome == "denied"
| summarize count() by audit.actor.id, audit.action
| order by count_ desc
```

</code-group>

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

<code-group>

```typescript [Input — server/plugins/evlog.ts]
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 }))
```

```sql [Output — audit_events row]
SELECT id, timestamp, payload->'audit'->>'action' AS action,
       payload->'audit'->>'outcome' AS outcome
FROM audit_events
WHERE id = 'ak_8f3c4b2a1e5d6f7c';

--          id          |       timestamp       |     action      | outcome
-- ---------------------+-----------------------+-----------------+---------
--  ak_8f3c4b2a1e5d6f7c | 2026-04-24 10:23:45.6 | invoice.refund  | success
```

</code-group>

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.

## Test what you recorded

`mockAudit()` captures every audit event on emit, from standalone `audit()`, `log.audit()`, and `log.set({ audit })`:

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

  captured.assertAudit({
    action: 'invoice.refund',
    target: { type: 'invoice', id: 'inv_889' },
    outcome: 'success',
  })

  captured.restore()
})
```

Prefer `assertAudit()` when a missing audit should fail the test with a readable message. Use `toIncludeAuditOf()` when you need a boolean inside `expect(...)`.

Always call `captured.restore()` in an `afterEach` (or wrap with a fixture) so a failing assertion never leaks into the next test.

## Every audit export

<table>
<thead>
  <tr>
    <th>
      Symbol
    </th>
    
    <th>
      Kind
    </th>
    
    <th>
      Notes
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        AuditFields
      </code>
    </td>
    
    <td>
      type
    </td>
    
    <td>
      Reserved field on the wide event
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        defineAuditAction(name, opts?)
      </code>
    </td>
    
    <td>
      factory
    </td>
    
    <td>
      Typed action registry, infers target shape
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        defineAuditCatalog(prefix, map)
      </code>
    </td>
    
    <td>
      factory
    </td>
    
    <td>
      Bundle of typed audit actions sharing a prefix
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        log.audit(fields)
      </code>
    </td>
    
    <td>
      method
    </td>
    
    <td>
      Sugar over <code>
        log.set({ audit })
      </code>
      
       + force-keep
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        log.audit.deny(reason, fields)
      </code>
    </td>
    
    <td>
      method
    </td>
    
    <td>
      Records a denied action
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        audit(fields)
      </code>
    </td>
    
    <td>
      function
    </td>
    
    <td>
      Standalone for scripts / jobs
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        withAudit({ action, target })(fn)
      </code>
    </td>
    
    <td>
      wrapper
    </td>
    
    <td>
      Auto-emit success / failure / denied
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        auditDiff(before, after)
      </code>
    </td>
    
    <td>
      helper
    </td>
    
    <td>
      Redact-aware JSON Patch for <code>
        changes
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mockAudit()
      </code>
    </td>
    
    <td>
      test util
    </td>
    
    <td>
      Capture audits on emit; <code>
        assertAudit()
      </code>
      
       or <code>
        toIncludeAuditOf()
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        auditEnricher(opts?)
      </code>
    </td>
    
    <td>
      enricher
    </td>
    
    <td>
      Auto-fill request / runtime / tenant context
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        auditOnly(drain, { await? })
      </code>
    </td>
    
    <td>
      wrapper
    </td>
    
    <td>
      Routes only events with an <code>
        audit
      </code>
      
       field
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        signed(drain, opts)
      </code>
    </td>
    
    <td>
      wrapper
    </td>
    
    <td>
      Generic integrity wrapper (hmac / hash-chain)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        auditRedactPreset
      </code>
    </td>
    
    <td>
      config
    </td>
    
    <td>
      Strict PII for audit events
    </td>
  </tr>
</tbody>
</table>

Everything ships from the main `evlog` entrypoint.

---

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


## Sitemap

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