---
title: "Drains & Integrity"
description: "auditEnricher to auto-fill request context, auditOnly to route audits to a dedicated sink, and signed for tamper-evident HMAC or hash-chain integrity."
canonical_url: "https://www.evlog.dev/use-cases/audit/pipeline"
---
# Drains & Integrity

> auditEnricher to auto-fill request context, auditOnly to route audits to a dedicated sink, and signed for tamper-evident HMAC or hash-chain integrity.

Three building blocks: `auditEnricher` fills context, `auditOnly` routes audits to a dedicated drain, and `signed` adds tamper-evident integrity. Each is opt-in and replaceable.

## `auditEnricher()`

`auditEnricher()` populates `event.audit.context.{requestId, traceId, ip, userAgent, tenantId}`. Skip it and ship a custom enricher if your strategy differs.

```typescript [server/plugins/evlog.ts]
import { auditEnricher } from 'evlog'

nitro.hooks.hook('evlog:enrich', auditEnricher())
```

For multi-tenant apps and custom session bridges, pass options:

```typescript
nitro.hooks.hook('evlog:enrich', auditEnricher({
  tenantId: ctx => ctx.event.tenant as string | undefined,
  bridge: { getSession: async ctx => readSessionActor(ctx.headers) },
}))
```

Without `auditEnricher`, `audit.context` stays empty, and auditors and incident responders need at least `requestId` and `ip` to triangulate a recorded action.

## `auditOnly()`

<tip>

**Why filter audits to a separate drain?** Three reasons: **cost** (audit volume is tiny next to product telemetry, so keep them separate and 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).

</tip>

`auditOnly(drain)` only forwards events with an `audit` field. Compose with **any** drain:

```typescript
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', apiKey: process.env.AXIOM_AUDIT_API_KEY }),
))
```

Set `await: true` to make audit writes synchronous (no fire-and-forget for audits, so crash-safe by default):

```typescript
auditOnly(createFsDrain({ dir: '.audit' }), { await: true })
```

The `await: true` flag costs one awaited drain call per request that records an audit: the drain step that carries the audit finishes only once the write is done, instead of firing it and moving on. With `waitUntil` set (Next.js, Cloudflare Workers, Vercel Edge), that step runs after the response and the runtime stays alive until the write lands. An audit recorded inside `log.fork()` is drained by the child, so `await log.fork(...)` when the write must land before the handler returns. For compliance-grade audits, the trade-off is always worth it.

## `signed()`

`signed(drain, opts)` adds tamper-evident integrity. Two strategies:

<table>
<thead>
  <tr>
    <th>
      Strategy
    </th>
    
    <th>
      What it adds
    </th>
    
    <th>
      Use case
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        'hmac'
      </code>
    </td>
    
    <td>
      <code>
        event.audit.signature
      </code>
      
       (HMAC of the canonical event)
    </td>
    
    <td>
      Single-event integrity check (any later mutation fails verification).
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        'hash-chain'
      </code>
    </td>
    
    <td>
      <code>
        event.audit.prevHash
      </code>
      
       and <code>
        event.audit.hash
      </code>
    </td>
    
    <td>
      A verifiable chain — deletions and reordering also become detectable.
    </td>
  </tr>
</tbody>
</table>

<tip>

**What signed() actually buys you.** Detection, not prevention. Anyone with write access to the underlying drain 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.

</tip>

### HMAC

Each event gets a signature. Tampering with one row breaks that row's verification, but doesn't break later rows.

```typescript
import { signed } from 'evlog'

signed(drain, { strategy: 'hmac', secret: process.env.AUDIT_SECRET! })
```

### Hash-chain

Each event references the previous event's hash. Deleting any row breaks the chain forward of that point, so the verifier can pinpoint the exact row that was tampered with.

```typescript
signed(drain, {
  strategy: 'hash-chain',
  state: {
    load: () => fs.readFile('.audit/head', 'utf8').catch(() => null),
    save: (h) => fs.writeFile('.audit/head', h),
  },
})
```

The `state` config is required for cross-process or durable chains: load the previous head hash from your own store (Redis, Postgres, file) before each event, save the new head after.

<hash-chain-tamper>



</hash-chain-tamper>

<note>

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

</note>

---

- [Recording](https://www.evlog.dev/use-cases/audit/recording)
- [Compliance](https://www.evlog.dev/use-cases/audit/compliance)


## Sitemap

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