---
title: "Recording Events"
description: "log.audit and log.audit.deny, standalone audit(), withAudit, the defineAuditAction and defineAuditCatalog registries, and auditDiff."
canonical_url: "https://www.evlog.dev/use-cases/audit/recording"
---
# Recording Events

> log.audit and log.audit.deny, standalone audit(), withAudit, the defineAuditAction and defineAuditCatalog registries, and auditDiff.

Five APIs cover every shape of audit recording: in-request, denied, standalone, auto-instrumented, and typed.

## `log.audit()`

`log.audit()` is sugar over `log.set({ audit: ... })` plus tail-sample force-keep:

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

This is the form you'll use most. The audit event lands on the same wide event as the rest of the request.

## `log.audit.deny()`

`log.audit.deny(reason, fields)` records AuthZ-denied actions. Most teams forget to log denials, but they're exactly what auditors and security teams ask for:

<code-group>

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

```json [Output — denied]
{
  "level": "warn",
  "service": "billing-api",
  "method": "POST",
  "path": "/api/invoices/inv_889/refund",
  "status": 403,
  "duration": "12ms",
  "durationMs": 12,
  "requestId": "9c3f7d12-8a45-4e60-b8a9-1f0d4c5e6e7d",
  "audit": {
    "action": "invoice.refund",
    "actor": { "type": "user", "id": "usr_intruder" },
    "target": { "type": "invoice", "id": "inv_889" },
    "outcome": "denied",
    "reason": "Insufficient permissions",
    "version": 1,
    "idempotencyKey": "ak_d12c3a4f5b6e7d8c",
    "context": {
      "requestId": "9c3f7d12-8a45-4e60-b8a9-1f0d4c5e6e7d",
      "ip": "203.0.113.7"
    }
  }
}
```

</code-group>

## Standalone `audit()`

For non-request contexts (jobs, scripts, CLIs), use the standalone `audit()`:

<code-group>

```typescript [scripts/cleanup.ts]
import { audit } from 'evlog'

audit({
  action: 'cron.cleanup',
  actor: { type: 'system', id: 'cron' },
  target: { type: 'job', id: 'cleanup-stale-sessions' },
  outcome: 'success',
})
```

```json [Output — wide event]
{
  "level": "info",
  "service": "billing-api",
  "audit": {
    "action": "cron.cleanup",
    "actor": { "type": "system", "id": "cron" },
    "target": { "type": "job", "id": "cleanup-stale-sessions" },
    "outcome": "success",
    "version": 1,
    "idempotencyKey": "ak_2b8e1f9d4c6a7b3e"
  }
}
```

</code-group>

<note>

Standalone `audit()` events have no `requestId`, no `context.ip`, no `userAgent`, because there is no request to enrich from. Add your own context manually (`context: { jobId, queue, runId }`) when it matters for forensics.

</note>

## `defineAuditAction()`

Define audit actions in one place to avoid magic strings and get full type-safety on `target`:

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

Pair this with the action dictionary from [Schema → Action naming](https://www.evlog.dev/use-cases/audit/schema#action-naming).

## `defineAuditCatalog()`

For more than a handful of actions, group them in a typed **catalog** instead of declaring `defineAuditAction` one-by-one. Same convention as error catalogs: `UPPER_SNAKE_CASE` keys, `lower.dot.case` prefix, wire `action` is `${prefix}.${KEY}`.

<code-group>

```typescript [audit/billing.ts]
import { defineAuditCatalog } from 'evlog'

export const billingAudit = defineAuditCatalog('billing', {
  INVOICE_REFUND: {
    target: 'invoice',
    severity: 'high',
    requiresChanges: true,
    description: 'Refund an invoice to the customer',
    redactPaths: ['cardNumber'],
  },
  INVOICE_CREATE:      { target: 'invoice' },
  INVOICE_VOID:        { target: 'invoice', severity: 'high', requiresReason: true },
  SUBSCRIPTION_CANCEL: { target: 'subscription', severity: 'high' },
})
```

```typescript [server/api/refund.post.ts]
import { billingAudit } from '~/audit/billing'

log.audit(billingAudit.INVOICE_REFUND({
  actor: { type: 'user', id: user.id },
  target: { id: 'inv_889' }, // type inferred as 'invoice'
  outcome: 'success',
}))
```

</code-group>

Each entry produces a thin wrapper around `defineAuditAction` (target type is fixed at definition time, action name is auto-prefixed). Catalog metadata is exposed on each factory and on `_actions` / `_prefix`:

```typescript
billingAudit.INVOICE_REFUND.action           // 'billing.INVOICE_REFUND' (literal type)
billingAudit.INVOICE_REFUND.target           // 'invoice'
billingAudit.INVOICE_REFUND.severity         // 'high'
billingAudit.INVOICE_REFUND.requiresChanges // true
billingAudit.INVOICE_REFUND.redactPaths      // ['cardNumber']
billingAudit._actions                        // readonly ['billing.INVOICE_REFUND', ...]
```

<table>
<thead>
  <tr>
    <th>
      Entry field
    </th>
    
    <th>
      Purpose
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        target
      </code>
    </td>
    
    <td>
      Default <code>
        target.type
      </code>
      
       injected at call sites
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        description
      </code>
    </td>
    
    <td>
      Human-readable label for docs, SIEM rules, review tooling
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        severity
      </code>
    </td>
    
    <td>
      <code>
        'low' | 'medium' | 'high' | 'critical'
      </code>
      
       — alerting and review priority
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        requiresChanges
      </code>
    </td>
    
    <td>
      Document that callers should attach <code>
        changes
      </code>
      
       (e.g. via <code>
        auditDiff
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        requiresReason
      </code>
    </td>
    
    <td>
      Document that callers should attach <code>
        reason
      </code>
      
       (especially denials)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        redactPaths
      </code>
    </td>
    
    <td>
      Default paths for <code>
        auditDiff({ redactPaths: [...] })
      </code>
      
       on this action
    </td>
  </tr>
</tbody>
</table>

### `defineAuditAction` vs `defineAuditCatalog`: when to choose

Both produce the same call-site factory shape. Pick by scale:

- **defineAuditAction(action, opts?)**: one-off actions, or per-file organisation in very large repos. Mirrors `defineError`. Equivalent to a catalog with a single entry but with no prefix derivation: you write the full wire `action` directly.
- **defineAuditCatalog(prefix, map)**: group anything beyond a handful of related actions under one prefix. Mirrors `defineErrorCatalog`. The wire `action` is auto-derived as `${prefix}.${KEY}`, catalog metadata (`_actions`, `_prefix`) is exposed for introspection, and a single `declare module 'evlog'` line surfaces the whole bundle in the typed `AuditAction` union.

You can mix the two in the same codebase. Keep cross-cutting one-off actions as `defineAuditAction`, group bounded contexts (`billing`, `auth`, `subscription`) as catalogs.

### Type-safe actions everywhere (opt-in)

Mirror the error catalog augmentation by augmenting `RegisteredAuditCatalogs`:

```typescript
import type { billingAudit } from './audit/billing'

declare module 'evlog' {
  interface RegisteredAuditCatalogs {
    billing: typeof billingAudit
  }
}
```

This surfaces the union of all registered actions on the typed `AuditAction` export, useful for shared helpers, dashboards, and refactor-safe comparisons.

<callout color="primary" icon="i-lucide-arrow-right" to="https://www.evlog.dev/learn/catalogs">

**Going further.** The dedicated [Catalogs page](https://www.evlog.dev/learn/catalogs) covers the scaling story (single file → folder → feature → npm package) for both error and audit catalogs, plus npm packaging, composition patterns, and the type-augmentation deep dive.

</callout>

## `auditDiff()`

For mutating actions, use `auditDiff()` to produce a compact, redact-aware JSON Patch:

<warning>

**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 `paid` → `refunded`), not *what bytes changed* (a `lastModified` timestamp ticked). A noisy `changes` field is the fastest way to make audit logs unreadable.

</warning>

<code-group>

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

```json [Output — changes patch]
{
  "audit": {
    "action": "user.update",
    "actor": { "type": "user", "id": "usr_42" },
    "target": { "type": "user", "id": "usr_99" },
    "outcome": "success",
    "changes": [
      { "op": "replace", "path": "/email", "from": "old@example.com", "to": "new@example.com" },
      { "op": "replace", "path": "/role", "from": "member", "to": "admin" },
      { "op": "replace", "path": "/password", "from": "[REDACTED]", "to": "[REDACTED]" }
    ],
    "version": 1,
    "idempotencyKey": "ak_5e7d8f9a0b1c2d3e"
  }
}
```

</code-group>

## `withAudit()`: auto-instrumentation

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

<tip>

**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").

</tip>

<code-group>

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

```json [Output — success]
{
  "audit": {
    "action": "invoice.refund",
    "actor": { "type": "user", "id": "usr_42" },
    "target": { "type": "invoice", "id": "inv_889" },
    "outcome": "success",
    "version": 1,
    "idempotencyKey": "ak_8f3c4b2a1e5d6f7c",
    "correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599"
  }
}
```

```json [Output — failure]
{
  "level": "error",
  "audit": {
    "action": "invoice.refund",
    "actor": { "type": "user", "id": "usr_42" },
    "target": { "type": "invoice", "id": "inv_889" },
    "outcome": "failure",
    "reason": "Stripe error: charge already refunded",
    "version": 1,
    "idempotencyKey": "ak_4c5d6e7f8a9b0c1d",
    "correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599"
  },
  "error": {
    "name": "StripeError",
    "message": "charge already refunded",
    "stack": "..."
  }
}
```

```json [Output — denied]
{
  "level": "warn",
  "audit": {
    "action": "invoice.refund",
    "actor": { "type": "system", "id": "anonymous" },
    "target": { "type": "invoice", "id": "inv_889" },
    "outcome": "denied",
    "reason": "Anonymous refund denied",
    "version": 1,
    "idempotencyKey": "ak_d12c3a4f5b6e7d8c",
    "correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599"
  }
}
```

</code-group>

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.

---

- [Schema](https://www.evlog.dev/use-cases/audit/schema)
- [Drains & Integrity](https://www.evlog.dev/use-cases/audit/pipeline)


## Sitemap

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