Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/audit-logs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'evlog': minor
---

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.

New API on the main `evlog` entrypoint:

- `AuditFields` reserved on `BaseWideEvent` (`action`, `actor`, `target`, `outcome`, `reason`, `changes`, `causationId`, `correlationId`, `version`, `idempotencyKey`, `context`, `signature`, `prevHash`, `hash`) plus `AUDIT_SCHEMA_VERSION`.
- `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.
- Standalone `audit(fields)` for jobs / scripts / CLIs.
- `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).
- `defineAuditAction(name, opts)` typed action registry, `auditDiff(before, after)` redact-aware JSON Patch helper, `mockAudit()` test utility (`expectIncludes`, `expectActionCount`, `clear`, `restore`).
- `auditEnricher({ tenantId?, betterAuth? })` enricher that auto-fills `event.audit.context` (`requestId`, `traceId`, `ip`, `userAgent`, `tenantId`) and optionally bridges `actor` from a session.
- `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.
- `auditRedactPreset` strict PII preset composable with existing `RedactConfig`.

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.
195 changes: 195 additions & 0 deletions apps/docs/app/components/features/FeatureAudit.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { Motion } from 'motion-v'

const prefersReducedMotion = ref(false)

const props = defineProps<{
link?: string
linkLabel?: string
}>()

const pills = [
{ label: 'log.audit()', icon: 'i-lucide-shield-check' },
{ label: 'auditOnly()', icon: 'i-lucide-filter' },
{ label: 'signed()', icon: 'i-lucide-fingerprint' },
{ label: 'auditDiff()', icon: 'i-lucide-git-compare' },
{ label: 'mockAudit()', icon: 'i-lucide-flask-conical' },
]

const benefits = [
{
icon: 'i-lucide-shield-check',
title: 'Reserved schema',
text: 'Typed action, actor, target, outcome, changes, causation. No magic strings.',
},
{
icon: 'i-lucide-fingerprint',
title: 'Tamper-evident',
text: 'HMAC signatures or hash-chain integrity composable on any drain.',
},
{
icon: 'i-lucide-rotate-ccw',
title: 'Safe retries',
text: 'Deterministic idempotency keys auto-derived per audit event.',
},
{
icon: 'i-lucide-key-round',
title: 'Compose, do not replace',
text: 'Reuses your drains, enrichers, redact, sampling. No parallel pipeline.',
},
]

onMounted(() => {
prefersReducedMotion.value = window.matchMedia('(prefers-reduced-motion: reduce)').matches
})
</script>

<template>
<section class="py-24 md:py-32">
<div class="grid gap-6 lg:grid-cols-2 *:min-w-0">
<div class="flex flex-col gap-6">
<Motion
:initial="prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 20 }"
:while-in-view="{ opacity: 1, y: 0 }"
:transition="{ duration: 0.5 }"
:in-view-options="{ once: true }"
>
<div>
<p v-if="$slots.headline" class="section-label">
<slot name="headline" mdc-unwrap="p" />
</p>
<div class="relative mb-4">
<h2 class="section-title">
<slot name="title" mdc-unwrap="p" /><span class="text-primary">.</span>
</h2>
<div aria-hidden="true" class="absolute inset-0 section-title blur-xs animate-pulse pointer-events-none">
<slot name="title" mdc-unwrap="p" /><span class="text-primary">.</span>
</div>
</div>
<p v-if="$slots.description" class="max-w-md text-sm leading-relaxed text-muted">
<slot name="description" mdc-unwrap="p" />
</p>
<div class="mt-5 flex flex-wrap gap-2">
<span
v-for="pill in pills"
:key="pill.label"
class="inline-flex items-center gap-1.5 border border-muted bg-elevated/50 px-3 py-1 font-mono text-[11px] text-muted"
>
<UIcon :name="pill.icon" class="size-3 text-emerald-500" />
{{ pill.label }}
</span>
</div>
<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">
{{ props.linkLabel || 'Learn more' }}
<UIcon name="i-lucide-arrow-right" class="size-3" />
</NuxtLink>
</div>
</Motion>

<Motion
:initial="prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 20 }"
:while-in-view="{ opacity: 1, y: 0 }"
:transition="{ duration: 0.5, delay: 0.15 }"
:in-view-options="{ once: true }"
>
<div class="space-y-4">
<div
v-for="benefit in benefits"
:key="benefit.title"
class="flex items-start gap-3"
>
<UIcon :name="benefit.icon" class="size-4 mt-0.5 shrink-0 text-emerald-500" />
<div>
<p class="font-mono text-xs text-highlighted">
{{ benefit.title }}
</p>
<p class="mt-0.5 text-xs leading-relaxed text-dimmed">
{{ benefit.text }}
</p>
</div>
</div>
</div>
</Motion>
</div>

<Motion
:initial="prefersReducedMotion ? { opacity: 1 } : { opacity: 0, y: 20 }"
:while-in-view="{ opacity: 1, y: 0 }"
:transition="{ duration: 0.5, delay: 0.1 }"
:in-view-options="{ once: true }"
>
<div class="overflow-hidden border border-muted bg-default">
<div class="flex items-center gap-2 border-b border-muted px-4 py-3">
<div class="flex gap-1.5">
<div class="size-3 rounded-full bg-accented" />
<div class="size-3 rounded-full bg-accented" />
<div class="size-3 rounded-full bg-accented" />
</div>
<span class="ml-3 font-mono text-xs text-dimmed">audit.jsonl</span>
<div class="ml-auto flex items-center gap-1.5">
<span class="inline-flex items-center gap-1 font-mono text-[10px] text-emerald-500">
<UIcon name="i-lucide-shield-check" class="size-3" />
hash-chain
</span>
</div>
</div>

<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">
<!-- eslint-disable vue/multiline-html-element-content-newline -->
<pre><code>log.<span class="text-amber-400">audit</span>({
<span class="text-sky-400">action</span>: <span class="text-emerald-400">'invoice.refund'</span>,
<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 },
<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> },
<span class="text-sky-400">outcome</span>: <span class="text-emerald-400">'success'</span>,
<span class="text-sky-400">reason</span>: <span class="text-emerald-400">'Customer requested refund'</span>,
})</code></pre>
<!-- eslint-enable -->
</div>

<div class="p-5 font-mono text-xs sm:text-sm leading-relaxed overflow-x-auto">
<div class="mb-3 flex items-baseline gap-3">
<span class="font-medium text-emerald-500">INFO</span>
<span class="text-violet-400">POST</span>
<span class="text-amber-400">/api/refund</span>
<span class="ml-auto text-dimmed">(82ms)</span>
</div>
<div class="space-y-1 border-l-2 border-emerald-500/30 pl-4">
<div>
<span class="text-sky-400">audit.action</span><span class="text-dimmed">:</span>
<span class="text-emerald-400"> "invoice.refund"</span>
</div>
<div>
<span class="text-sky-400">audit.actor</span><span class="text-dimmed">:</span>
<span class="text-muted"> &#123; type: "user", id: "u_42" &#125;</span>
</div>
<div>
<span class="text-sky-400">audit.target</span><span class="text-dimmed">:</span>
<span class="text-muted"> &#123; type: "invoice", id: "inv_889" &#125;</span>
</div>
<div>
<span class="text-sky-400">audit.outcome</span><span class="text-dimmed">:</span>
<span class="text-emerald-400"> "success"</span>
</div>
<div>
<span class="text-sky-400">audit.context</span><span class="text-dimmed">:</span>
<span class="text-muted"> &#123; requestId, traceId, ip, userAgent &#125;</span>
</div>
<div>
<span class="text-sky-400">audit.idempotencyKey</span><span class="text-dimmed">:</span>
<span class="text-pink-400"> "8f2c…"</span>
</div>
<div>
<span class="text-sky-400">audit.prevHash</span><span class="text-dimmed">:</span>
<span class="text-dimmed"> "a1b2…"</span>
</div>
<div>
<span class="text-sky-400">audit.hash</span><span class="text-dimmed">:</span>
<span class="text-dimmed"> "c3d4…"</span>
</div>
</div>
</div>
</div>
</Motion>
</div>
</section>
</template>
15 changes: 15 additions & 0 deletions apps/docs/content/0.landing.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,21 @@ Wide events and structured errors for TypeScript. One log per request, full cont
Two-tier filtering: head sampling drops noise by level, tail sampling rescues critical events. Never miss errors, slow requests, or critical paths.
:::

:::features-feature-audit
---
link: /logging/audit
link-label: Audit logs guide
---
#headline
Audit Logs

#title
Compliance-ready :br by composition

#description
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.
:::

:::features-feature-ai-sdk
---
link: /logging/ai-sdk
Expand Down
Loading
Loading