Skip to content

Commit 7060006

Browse files
authored
feat: add first-class audit logs (#302)
1 parent b3b3360 commit 7060006

20 files changed

Lines changed: 3305 additions & 36 deletions

File tree

.changeset/audit-logs.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'evlog': minor
3+
---
4+
5+
Add first-class audit logs as a thin layer over existing evlog primitives. Audit is not a parallel system: it is a typed `audit` field on the wide event plus a few opt-in helpers and drain wrappers. Companies already running evlog can enable audit logs by adding 1 enricher + 1 drain wrapper + `log.audit()`, with zero new sub-exports.
6+
7+
New API on the main `evlog` entrypoint:
8+
9+
- `AuditFields` reserved on `BaseWideEvent` (`action`, `actor`, `target`, `outcome`, `reason`, `changes`, `causationId`, `correlationId`, `version`, `idempotencyKey`, `context`, `signature`, `prevHash`, `hash`) plus `AUDIT_SCHEMA_VERSION`.
10+
- `log.audit(fields)` and `log.audit.deny(reason, fields)` on `RequestLogger` and the return value of `createLogger()`. Sugar over `log.set({ audit })` that also force-keeps the event through tail sampling.
11+
- Standalone `audit(fields)` for jobs / scripts / CLIs.
12+
- `withAudit({ action, target, actor? })(fn)` higher-order wrapper that auto-emits `success` / `failure` / `denied` based on the wrapped function's outcome (with `AuditDeniedError` for AuthZ refusals).
13+
- `defineAuditAction(name, opts)` typed action registry, `auditDiff(before, after)` redact-aware JSON Patch helper, `mockAudit()` test utility (`expectIncludes`, `expectActionCount`, `clear`, `restore`).
14+
- `auditEnricher({ tenantId?, betterAuth? })` enricher that auto-fills `event.audit.context` (`requestId`, `traceId`, `ip`, `userAgent`, `tenantId`) and optionally bridges `actor` from a session.
15+
- `auditOnly(drain, { await? })` drain wrapper that filters to events with `event.audit` set, optionally awaiting writes for crash safety. `signed(drain, { strategy: 'hmac' | 'hash-chain', ... })` generic tamper-evidence wrapper with pluggable `state.{load,save}` for hash chains.
16+
- `auditRedactPreset` strict PII preset composable with existing `RedactConfig`.
17+
18+
Audit events are always force-kept by tail sampling and get a deterministic `idempotencyKey` derived from `action + actor + target + timestamp` so retries are safe across drains. Schema is OTEL-compatible and the `actor.type === 'agent'` slot carries `model`, `tools`, `reason`, `promptId` for AI agent auditing. No new sub-exports were added.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
<script setup lang="ts">
2+
import { Motion } from 'motion-v'
3+
4+
const prefersReducedMotion = ref(false)
5+
6+
const props = defineProps<{
7+
link?: string
8+
linkLabel?: string
9+
}>()
10+
11+
const pills = [
12+
{ label: 'log.audit()', icon: 'i-lucide-shield-check' },
13+
{ label: 'auditOnly()', icon: 'i-lucide-filter' },
14+
{ label: 'signed()', icon: 'i-lucide-fingerprint' },
15+
{ label: 'auditDiff()', icon: 'i-lucide-git-compare' },
16+
{ label: 'mockAudit()', icon: 'i-lucide-flask-conical' },
17+
]
18+
19+
const benefits = [
20+
{
21+
icon: 'i-lucide-shield-check',
22+
title: 'Reserved schema',
23+
text: 'Typed action, actor, target, outcome, changes, causation. No magic strings.',
24+
},
25+
{
26+
icon: 'i-lucide-fingerprint',
27+
title: 'Tamper-evident',
28+
text: 'HMAC signatures or hash-chain integrity composable on any drain.',
29+
},
30+
{
31+
icon: 'i-lucide-rotate-ccw',
32+
title: 'Safe retries',
33+
text: 'Deterministic idempotency keys auto-derived per audit event.',
34+
},
35+
{
36+
icon: 'i-lucide-key-round',
37+
title: 'Compose, do not replace',
38+
text: 'Reuses your drains, enrichers, redact, sampling. No parallel pipeline.',
39+
},
40+
]
41+
42+
onMounted(() => {
43+
prefersReducedMotion.value = window.matchMedia('(prefers-reduced-motion: reduce)').matches
44+
})
45+
</script>
46+
47+
<template>
48+
<section class="py-24 md:py-32">
49+
<div class="grid gap-6 lg:grid-cols-2 *:min-w-0">
50+
<div class="flex flex-col gap-6">
51+
<Motion
52+
:initial="prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 20 }"
53+
:while-in-view="{ opacity: 1, y: 0 }"
54+
:transition="{ duration: 0.5 }"
55+
:in-view-options="{ once: true }"
56+
>
57+
<div>
58+
<p v-if="$slots.headline" class="section-label">
59+
<slot name="headline" mdc-unwrap="p" />
60+
</p>
61+
<div class="relative mb-4">
62+
<h2 class="section-title">
63+
<slot name="title" mdc-unwrap="p" /><span class="text-primary">.</span>
64+
</h2>
65+
<div aria-hidden="true" class="absolute inset-0 section-title blur-xs animate-pulse pointer-events-none">
66+
<slot name="title" mdc-unwrap="p" /><span class="text-primary">.</span>
67+
</div>
68+
</div>
69+
<p v-if="$slots.description" class="max-w-md text-sm leading-relaxed text-muted">
70+
<slot name="description" mdc-unwrap="p" />
71+
</p>
72+
<div class="mt-5 flex flex-wrap gap-2">
73+
<span
74+
v-for="pill in pills"
75+
:key="pill.label"
76+
class="inline-flex items-center gap-1.5 border border-muted bg-elevated/50 px-3 py-1 font-mono text-[11px] text-muted"
77+
>
78+
<UIcon :name="pill.icon" class="size-3 text-emerald-500" />
79+
{{ pill.label }}
80+
</span>
81+
</div>
82+
<NuxtLink v-if="props.link" :to="props.link" class="mt-4 inline-flex items-center gap-1.5 font-mono text-xs text-dimmed hover:text-primary transition-colors">
83+
{{ props.linkLabel || 'Learn more' }}
84+
<UIcon name="i-lucide-arrow-right" class="size-3" />
85+
</NuxtLink>
86+
</div>
87+
</Motion>
88+
89+
<Motion
90+
:initial="prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 20 }"
91+
:while-in-view="{ opacity: 1, y: 0 }"
92+
:transition="{ duration: 0.5, delay: 0.15 }"
93+
:in-view-options="{ once: true }"
94+
>
95+
<div class="space-y-4">
96+
<div
97+
v-for="benefit in benefits"
98+
:key="benefit.title"
99+
class="flex items-start gap-3"
100+
>
101+
<UIcon :name="benefit.icon" class="size-4 mt-0.5 shrink-0 text-emerald-500" />
102+
<div>
103+
<p class="font-mono text-xs text-highlighted">
104+
{{ benefit.title }}
105+
</p>
106+
<p class="mt-0.5 text-xs leading-relaxed text-dimmed">
107+
{{ benefit.text }}
108+
</p>
109+
</div>
110+
</div>
111+
</div>
112+
</Motion>
113+
</div>
114+
115+
<Motion
116+
:initial="prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 20 }"
117+
:while-in-view="{ opacity: 1, y: 0 }"
118+
:transition="{ duration: 0.5, delay: 0.1 }"
119+
:in-view-options="{ once: true }"
120+
>
121+
<div class="overflow-hidden border border-muted bg-default">
122+
<div class="flex items-center gap-2 border-b border-muted px-4 py-3">
123+
<div class="flex gap-1.5">
124+
<div class="size-3 rounded-full bg-accented" />
125+
<div class="size-3 rounded-full bg-accented" />
126+
<div class="size-3 rounded-full bg-accented" />
127+
</div>
128+
<span class="ml-3 font-mono text-xs text-dimmed">audit.jsonl</span>
129+
<div class="ml-auto flex items-center gap-1.5">
130+
<span class="inline-flex items-center gap-1 font-mono text-[10px] text-emerald-500">
131+
<UIcon name="i-lucide-shield-check" class="size-3" />
132+
hash-chain
133+
</span>
134+
</div>
135+
</div>
136+
137+
<div class="px-5 pt-4 pb-3 font-mono text-xs sm:text-sm leading-relaxed overflow-x-auto border-b border-muted/50">
138+
<!-- eslint-disable vue/multiline-html-element-content-newline -->
139+
<pre><code>log.<span class="text-amber-400">audit</span>({
140+
<span class="text-sky-400">action</span>: <span class="text-emerald-400">'invoice.refund'</span>,
141+
<span class="text-sky-400">actor</span>: { <span class="text-sky-400">type</span>: <span class="text-emerald-400">'user'</span>, <span class="text-sky-400">id</span>: user.id },
142+
<span class="text-sky-400">target</span>: { <span class="text-sky-400">type</span>: <span class="text-emerald-400">'invoice'</span>, <span class="text-sky-400">id</span>: <span class="text-emerald-400">'inv_889'</span> },
143+
<span class="text-sky-400">outcome</span>: <span class="text-emerald-400">'success'</span>,
144+
<span class="text-sky-400">reason</span>: <span class="text-emerald-400">'Customer requested refund'</span>,
145+
})</code></pre>
146+
<!-- eslint-enable -->
147+
</div>
148+
149+
<div class="p-5 font-mono text-xs sm:text-sm leading-relaxed overflow-x-auto">
150+
<div class="mb-3 flex items-baseline gap-3">
151+
<span class="font-medium text-emerald-500">INFO</span>
152+
<span class="text-violet-400">POST</span>
153+
<span class="text-amber-400">/api/refund</span>
154+
<span class="ml-auto text-dimmed">(82ms)</span>
155+
</div>
156+
<div class="space-y-1 border-l-2 border-emerald-500/30 pl-4">
157+
<div>
158+
<span class="text-sky-400">audit.action</span><span class="text-dimmed">:</span>
159+
<span class="text-emerald-400"> "invoice.refund"</span>
160+
</div>
161+
<div>
162+
<span class="text-sky-400">audit.actor</span><span class="text-dimmed">:</span>
163+
<span class="text-muted"> &#123; type: "user", id: "u_42" &#125;</span>
164+
</div>
165+
<div>
166+
<span class="text-sky-400">audit.target</span><span class="text-dimmed">:</span>
167+
<span class="text-muted"> &#123; type: "invoice", id: "inv_889" &#125;</span>
168+
</div>
169+
<div>
170+
<span class="text-sky-400">audit.outcome</span><span class="text-dimmed">:</span>
171+
<span class="text-emerald-400"> "success"</span>
172+
</div>
173+
<div>
174+
<span class="text-sky-400">audit.context</span><span class="text-dimmed">:</span>
175+
<span class="text-muted"> &#123; requestId, traceId, ip, userAgent &#125;</span>
176+
</div>
177+
<div>
178+
<span class="text-sky-400">audit.idempotencyKey</span><span class="text-dimmed">:</span>
179+
<span class="text-pink-400"> "8f2c…"</span>
180+
</div>
181+
<div>
182+
<span class="text-sky-400">audit.prevHash</span><span class="text-dimmed">:</span>
183+
<span class="text-dimmed"> "a1b2…"</span>
184+
</div>
185+
<div>
186+
<span class="text-sky-400">audit.hash</span><span class="text-dimmed">:</span>
187+
<span class="text-dimmed"> "c3d4…"</span>
188+
</div>
189+
</div>
190+
</div>
191+
</div>
192+
</Motion>
193+
</div>
194+
</section>
195+
</template>

apps/docs/content/0.landing.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,21 @@ Wide events and structured errors for TypeScript. One log per request, full cont
8989
Two-tier filtering: head sampling drops noise by level, tail sampling rescues critical events. Never miss errors, slow requests, or critical paths.
9090
:::
9191

92+
:::features-feature-audit
93+
---
94+
link: /logging/audit
95+
link-label: Audit logs guide
96+
---
97+
#headline
98+
Audit Logs
99+
100+
#title
101+
Compliance-ready :br by composition
102+
103+
#description
104+
First-class who-did-what trails as a thin layer on top of wide events. One enricher, one drain wrapper, one helper. Tamper-evident hash chains, denied actions, redact-aware diffs, and idempotency keys for safe retries — all from the main entrypoint, no parallel pipeline.
105+
:::
106+
92107
:::features-feature-ai-sdk
93108
---
94109
link: /logging/ai-sdk

0 commit comments

Comments
 (0)