选择与你的 drain 匹配的配方,将其放入,你就拥有了一个可检测篡改的审计日志。每个配方都在不同的 drain 上组合相同的基本组件(auditOnly、signed、可选的 await: true)。
磁盘上的审计日志
import { auditOnly, signed } from 'evlog'
import { createFsDrain } from 'evlog/fs'
nitro.hooks.hook('evlog:drain', auditOnly(
signed(createFsDrain({ dir: '.audit', maxFiles: 30 }), { strategy: 'hash-chain' }),
{ await: true },
))
{"audit":{"action":"invoice.refund","actor":{"type":"user","id":"usr_42"},"target":{"type":"invoice","id":"inv_889"},"outcome":"success","version":1,"idempotencyKey":"ak_8f3c4b2a1e5d6f7c","prevHash":null,"hash":"3f2c8e1a..."}}
{"audit":{"action":"user.update","actor":{"type":"user","id":"usr_42"},"target":{"type":"user","id":"usr_99"},"outcome":"success","version":1,"idempotencyKey":"ak_5e7d8f9a0b1c2d3e","prevHash":"3f2c8e1a...","hash":"9a1b4d7c..."}}
每一行的 prevHash 都与上一行的 hash 匹配。篡改任何一行都会破坏该位置之后的链,验证器会重新计算哈希并报告第一个不匹配项。
写入专用 Axiom 数据集的审计日志
import { auditOnly } from 'evlog'
import { createAxiomDrain } from 'evlog/axiom'
nitro.hooks.hook('evlog:drain', createAxiomDrain({ dataset: 'logs' }))
nitro.hooks.hook('evlog:drain', auditOnly(
createAxiomDrain({ dataset: 'audit', apiKey: process.env.AXIOM_AUDIT_API_KEY }),
))
['audit']
| where audit.action == "invoice.refund"
| summarize count() by audit.outcome, bin(_time, 1h)
['audit']
| where audit.outcome == "denied"
| summarize count() by audit.actor.id, audit.action
| order by count_ desc
拆分数据集意味着审计数据集可以拥有更长的保留期(7y)、更严格的访问控制和独立的计费项目,而不会影响管道的其余部分。
Postgres 中的审计日志
import { auditOnly } from 'evlog'
import type { DrainContext } from 'evlog'
const postgresAudit = async (ctx: DrainContext) => {
await db.insert(auditEvents).values({
id: ctx.event.audit!.idempotencyKey,
timestamp: new Date(ctx.event.timestamp),
payload: ctx.event,
}).onConflictDoNothing()
}
nitro.hooks.hook('evlog:drain', auditOnly(postgresAudit, { await: true }))
SELECT id, timestamp, payload->'audit'->>'action' AS action,
payload->'audit'->>'outcome' AS outcome
FROM audit_events
WHERE id = 'ak_8f3c4b2a1e5d6f7c';
-- id | timestamp | action | outcome
-- ---------------------+-----------------------+-----------------+---------
-- ak_8f3c4b2a1e5d6f7c | 2026-04-24 10:23:45.6 | invoice.refund | success
确定性的 idempotencyKey 使重试安全:重复插入会通过 ON CONFLICT DO NOTHING 合并。如果没有它,重试期间短暂的网络故障就会创建重复的审计行,而这正是你不希望发生的情况。
测试你记录的内容
mockAudit() 会在发出时捕获每个审计事件,包括来自独立 audit()、log.audit() 和 log.set({ audit }) 的事件:
import { mockAudit } from 'evlog'
it('退款并记录审计', async () => {
const captured = mockAudit()
await refundInvoice({ id: 'inv_889' }, { actor: { type: 'user', id: 'u1' } })
captured.assertAudit({
action: 'invoice.refund',
target: { type: 'invoice', id: 'inv_889' },
outcome: 'success',
})
captured.restore()
})
当缺失审计应当以可读消息使测试失败时,优先使用 assertAudit()。当你需要在 expect(...) 中使用布尔值时,使用 toIncludeAuditOf()。
始终在 afterEach 中调用 captured.restore()(或用 fixture 包裹),这样失败的断言就不会泄漏到下一个测试中。
每个审计导出
| 符号 | 类型 | 说明 |
|---|---|---|
AuditFields | 类型 | 宽事件上的保留字段 |
defineAuditAction(name, opts?) | 工厂 | 类型化的动作注册表,推断 target 形状 |
defineAuditCatalog(prefix, map) | 工厂 | 共享前缀的类型化审计动作集合 |
log.audit(fields) | 方法 | log.set({ audit }) 的语法糖 + 强制保留 |
log.audit.deny(reason, fields) | 方法 | 记录被拒绝的动作 |
audit(fields) | 函数 | 供脚本 / 作业独立使用 |
withAudit({ action, target })(fn) | 包装器 | 自动发出 success / failure / denied |
auditDiff(before, after) | 助手 | 面向 redact 的 JSON Patch,用于 changes |
mockAudit() | 测试工具 | 在发出时捕获审计;使用 assertAudit() 或 toIncludeAuditOf() |
auditEnricher(opts?) | enrichr | 自动填充请求 / 运行时 / 租户上下文 |
auditOnly(drain, { await? }) | 包装器 | 仅路由包含 audit 字段的事件 |
signed(drain, opts) | 包装器 | 通用完整性包装器(hmac / hash-chain) |
auditRedactPreset | 配置 | 审计事件的严格 PII 处理 |
所有内容都从主 evlog 入口导出。