- name
- build-audit-logs
- description
- Build or review audit trails in TypeScript/JavaScript apps using evlog (pipelines, typed actions, denials, retention, compliance-style reviews). For application code, not for extending the evlog package.
# Build or Review an Audit System with evlog
For **application developers** who either need to add an audit trail to their product, or who already have one and want it reviewed. Walks through the design calls, the end-to-end implementation, and a review checklist for an existing setup.
This skill assumes the audit lives in **your app**. To extend the evlog package itself (new audit helper, new drain wrapper), see the contributor skills under `.agents/skills/`.
## Quick reference: call-site cheat sheet
When you already know the system is wired and just need to remember the API:
| Situation | Helper |
|---|---|
| Inside a request handler, action succeeded | `log.audit({ action, actor, target, outcome: 'success' })` |
| Inside a request handler, AuthZ denial | `log.audit.deny('reason', { action, actor, target })` |
| Standalone job / script / CLI (no request) | `audit({ action, actor, target, outcome })` |
| Auto-record success / failure / denied for a function | `withAudit({ action, target }, fn)` |
| Recording a state change | add `changes: auditDiff(before, after)` |
| Centralised typed action vocabulary | `defineAuditCatalog('billing', { INVOICE_REFUND: { target: 'invoice' } })` — or `defineAuditAction('invoice.refund', { target: 'invoice' })` for one-offs |
| Asserting audits in tests | `mockAudit()` — `assertAudit()` or `toIncludeAuditOf()` |
`AuditFields` schema (always provide `action`, `actor`, `outcome`; `target` strongly recommended; the rest is filled in for you):
```ts
interface AuditFields {
action: string // 'invoice.refund'
actor: { type: 'user' | 'system' | 'api' | 'agent', id: string, email?, displayName?, model?, tools?, reason?, promptId? }
outcome: 'success' | 'failure' | 'denied'
target?: { type: string, id: string, [k: string]: unknown }
reason?: string
changes?: { before?: unknown, after?: unknown, patch?: AuditPatchOp[] }
causationId?: string
correlationId?: string
version?: number // defaults to AUDIT_SCHEMA_VERSION
idempotencyKey?: string // auto-derived from action+actor+target+timestamp
context?: { requestId?, traceId?, ip?, userAgent?, tenantId?, ... } // filled by auditEnricher
signature?: string // added by signed(drain, { strategy: 'hmac' })
prevHash?: string // added by signed(drain, { strategy: 'hash-chain' })
hash?: string // added by signed(drain, { strategy: 'hash-chain' })
}
```
## What "audit logging" actually means
An audit log answers a forensic question: **who did what, on which resource, when, from where, with which outcome.** That's a different shape from observability logs, which is why the operational rules differ:
| | Audit log | Observability log |
| -------------- | ----------------------------------------------- | ---------------------------------- |
| Question | "Who tried to do what, was it allowed?" | "How did this request behave?" |
| Sampling | Never (force-keep) | Often (head + tail) |
| Retention | 1 – 7 years (compliance) | 30 – 90 days |
| Mutability | Append-only, tamper-evident | Mutable, lossy |
| Audience | Auditors, security, legal | Engineers |
| Storage | Often dedicated (separate dataset / DB) | Shared with telemetry |
evlog ships the audit layer as a thin extension of its wide-event pipeline (a typed `audit` field on `BaseWideEvent` plus a few helpers and drain wrappers). The point is that you compose with the primitives the app already uses: same drains, same enrichers, same redact, same framework integration. There is no parallel system to maintain.
## Mental model
```text
log.audit(...) ──► sets event.audit ──► force-keep ──► auditEnricher ──► redact ──► every drain
└─► auditOnly(signed(fsDrain))
```
| Building block | Role | Required? |
| ------------------------------------------- | ------------------------------------------------------------------- | ----------------------- |
| `log.audit()` / `audit()` / `withAudit()` | Sets `event.audit` and force-keeps the event | Yes |
| `auditEnricher()` | Auto-fills `event.audit.context` (req / trace / ip / ua / tenantId) | Recommended |
| `auditOnly(drain)` | Filters the drain to events with `event.audit` set | Recommended |
| `signed(drain, ...)` | Adds tamper-evident integrity (HMAC or hash-chain) | Optional (compliance) |
| `auditRedactPreset` | Strict PII preset for audit events | Recommended |
| `mockAudit()` | Captures audit events in tests | Yes (in tests) |
## Design calls before writing code
Make these explicit and write them down somewhere a security reviewer can find. Without a written rule, the system can't be audited. Auditors look for the policy first, then the enforcement.
### 1. Where do audits live?
| Drain | Use when | Trade-offs |
| --------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| **FS** (`evlog/fs` + `signed`) | Self-hosted, simple, you control the disk | Manual rotation/backup; single-process unless you persist hash-chain `state` externally |
| **Dedicated Axiom dataset** | You already use Axiom | Easy queries, separate retention/billing; cost scales with volume |
| **Postgres / Neon / Aurora** | You want SQL queries, joins with app data | Need a schema, indexes, retention job; idempotency key prevents duplicates |
| **S3 + Object Lock** | Append-only WORM compliance (HIPAA / FINRA) | Read latency; pair with a queryable mirror (Athena) |
| **Multiple drains** | Different audiences (engineers ↔ legal) | Use `auditOnly` per drain; drains fail in isolation by design |
> **Rule of thumb.** Pick at least two: a queryable one (Axiom / Postgres) for day-to-day forensics + an append-only one (FS journal with hash-chain, or S3 Object Lock) as the compliance artefact. The two-drain pattern protects against vendor outages and admin mistakes on the queryable side.
### 2. Do you need integrity (`signed`)?
Yes if any of:
- A compliance framework requires tamper-evidence (SOC2 CC7, HIPAA §164.312(c)(1), PCI 10.5).
- The drain is mutable by engineers / admins.
- You may need to prove to a regulator that no events were modified after the fact.
Skip if:
- Drain is already WORM (S3 Object Lock, BigQuery append-only, Postgres with row-level immutability + monitored DDL).
- You're prototyping.
Strategies:
- `'hmac'`: per-event signature; quick to verify; rotate `secret` annually and embed a key id (extend `AuditFields`).
- `'hash-chain'`: sequence integrity; deleting a row breaks the chain forward; persist `state.{load,save}` if you run multiple processes (Redis is the typical store).
### 3. Multi-tenancy?
If the app is multi-tenant, **tenant isolation on every audit event is non-negotiable.** A query that mixes tenants is a privacy incident. Wire it once in the enricher:
```ts
auditEnricher({ tenantId: ctx => resolveTenant(ctx) })
```
If the app uses Better Auth, `auditEnricher` can also bridge the authenticated session into `audit.context`. See the Better Auth integration (`evlog/better-auth`, https://www.evlog.dev/use-cases/better-auth/overview) for wiring `identifyUser` alongside the audit pipeline.
Then either (a) partition the audit dataset by `audit.context.tenantId`, or (b) one drain per tenant if hard isolation is required. Never query audits without a tenant filter.
### 4. Retention
Pick a window per drain and document it. Enforce at the drain layer, not in app code, because the drain already has audited mechanisms for it (lifecycle policies, `DELETE` jobs, dataset retention).
| Framework | Typical retention |
| --------- | ------------------------------------------------------------------- |
| SOC2 | 1 year minimum, 7 years recommended |
| HIPAA | 6 years |
| PCI DSS | 1 year (3 months immediately accessible) |
| GDPR | "As long as necessary" — see "GDPR vs append-only" below |
How to enforce per drain:
- **FS**: `createFsDrain({ maxFiles })` + a daily compactor.
- **Postgres**: `DELETE FROM audit_events WHERE timestamp < now() - interval '7 years'` on a cron.
- **Axiom / Datadog / Loki**: dataset-level retention policy.
### 5. GDPR vs append-only
The right to be forgotten collides with audit immutability. Recommended pattern:
1. Keep audit rows immutable and chain-verified.
2. Encrypt PII fields (email, name, IP) 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 (crypto-shredding).
A built-in `cryptoShredding` helper is on the roadmap; until then, encrypt in a custom enricher.
## Step-by-step buildout
### Step 1: Wire the pipeline (one-time)
The wiring shape is the same in every framework: register `auditEnricher()` so `event.audit.context` gets `requestId`, `traceId`, `ip`, `userAgent`, and (if configured) `tenantId` automatically, then add a main drain plus an audit-only drain.
The minimal Nuxt / Nitro setup looks like this:
```ts
// server/plugins/evlog.ts
import { auditEnricher, auditOnly, signed } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
import { createFsDrain } from 'evlog/fs'
export default defineNitroPlugin((nitroApp) => {
const auditSink = auditOnly(
signed(createFsDrain({ dir: '.audit/' }), { strategy: 'hash-chain' }),
{ await: true },
)
const main = createAxiomDrain({ dataset: 'logs' })
nitroApp.hooks.hook('evlog:enrich', auditEnricher({
tenantId: ctx => ctx.headers?.['x-tenant-id'],
}))
nitroApp.hooks.hook('evlog:drain', async (ctx) => {
await Promise.all([main(ctx), auditSink(ctx)])
})
})
```
For Hono, Express, Next.js, or standalone scripts / workers, see [`references/framework-wiring.md`](references/framework-wiring.md). The pattern is identical. Only the framework integration helper changes.
### Step 2: Define the action vocabulary
Audits get queried and alerted on by `audit.action`. A typo is a missing alert, so centralise the list. For a bounded context with several actions, prefer a catalog: one prefix, typed keys, autocomplete on the wire format `${prefix}.${KEY}`:
```ts
// app/audit/billing.ts
import { defineAuditCatalog } from 'evlog'
export const billingAudit = defineAuditCatalog('billing', {
INVOICE_REFUND: { target: 'invoice' },
PLAN_CHANGE: { target: 'subscription' },
})
```
At the call site: `log.audit(billingAudit.INVOICE_REFUND({ actor, target, outcome: 'success' }))`.
Add the opt-in `declare module 'evlog' { interface RegisteredAuditCatalogs { billing: typeof billingAudit } }` augmentation to get autocomplete everywhere. For one-off actions that don't fit a catalog, `defineAuditAction` still works:
```ts
// app/audit/actions.ts
import { defineAuditAction } from 'evlog'
export const InvoiceRefund = defineAuditAction('invoice.refund', { target: 'invoice' })
export const ApiKeyRevoke = defineAuditAction('apiKey.revoke', { target: 'apiKey' })
```
Catalog conventions and scaling recipes (folder per domain, npm packages per bounded context): https://www.evlog.dev/learn/catalogs
Naming conventions:
- `noun.verb` (`invoice.refund`, not `refundInvoice`).
- Past tense if the audit is logged after the fact (`invoice.refunded`); present tense when wrapped by `withAudit()` (which resolves the outcome itself).
- Lowercase, dot-delimited, no spaces: for hand-written action ids (`defineAuditAction`, inline `log.audit`). Catalog entries follow the catalog convention instead: UPPER_SNAKE_CASE keys under a lowercase prefix, producing wire actions like `billing.INVOICE_REFUND`. That is intentional. Do not lowercase the keys.
### Step 3: Instrument call sites
Ver no GitHub