选择与你的 sink 匹配的配方,把它放进去,你就拥有了一个防篡改的审计日志。每个配方都在不同的 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
拆分数据集意味着审计数据集可以拥有更长的保留期(7 年)、更严格的访问控制,以及单独的计费项——而无需影响流水线的其余部分。
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 包裹),这样失败的断言就不会泄漏到下一个测试中。
API 参考
| 符号 | 类型 | 说明 |
|---|---|---|
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 入口导出。