五个 API 覆盖了审计记录的所有形态:请求内、被拒绝、独立、自动埋点和类型化。
log.audit()
log.audit() 是 log.set({ audit: ... }) 加上尾部采样强制保留的语法糖:
log.audit({
action: 'invoice.refund',
actor: { type: 'user', id: user.id },
target: { type: 'invoice', id: 'inv_889' },
outcome: 'success',
})
// 严格等价于:
log.set({ audit: { action: 'invoice.refund', /* ... */, version: 1 } })
这是你最常使用的形式。审计事件会落在与请求其余部分相同的宽事件上。
log.audit.deny()
log.audit.deny(reason, fields) 用于记录被 AuthZ 拒绝的操作。大多数团队都会忘记记录拒绝,但这恰恰是审计员和安全团队最关心的内容:
if (!user.canRefund(invoice)) {
log.audit.deny('权限不足', {
action: 'invoice.refund',
actor: { type: 'user', id: user.id },
target: { type: 'invoice', id: invoice.id },
})
throw createError({ status: 403, message: 'Forbidden' })
}
{
"level": "warn",
"service": "billing-api",
"method": "POST",
"path": "/api/invoices/inv_889/refund",
"status": 403,
"duration": "12ms",
"requestId": "9c3f7d12-8a45-4e60-b8a9-1f0d4c5e6e7d",
"audit": {
"action": "invoice.refund",
"actor": { "type": "user", "id": "usr_intruder" },
"target": { "type": "invoice", "id": "inv_889" },
"outcome": "denied",
"reason": "权限不足",
"version": 1,
"idempotencyKey": "ak_d12c3a4f5b6e7d8c",
"context": {
"requestId": "9c3f7d12-8a45-4e60-b8a9-1f0d4c5e6e7d",
"ip": "203.0.113.7"
}
}
}
独立 audit()
对于非请求上下文(任务、脚本、CLI),请使用独立的 audit():
import { audit } from 'evlog'
audit({
action: 'cron.cleanup',
actor: { type: 'system', id: 'cron' },
target: { type: 'job', id: 'cleanup-stale-sessions' },
outcome: 'success',
})
{
"level": "info",
"service": "billing-api",
"audit": {
"action": "cron.cleanup",
"actor": { "type": "system", "id": "cron" },
"target": { "type": "job", "id": "cleanup-stale-sessions" },
"outcome": "success",
"version": 1,
"idempotencyKey": "ak_2b8e1f9d4c6a7b3e"
}
}
audit() 事件没有 requestId、没有 context.ip、也没有 userAgent —— 因为没有可供补充信息的请求。当取证需要时,请手动添加你自己的上下文(context: { jobId, queue, runId })。defineAuditAction()
在一个地方定义审计动作,避免魔法字符串,并让 target 获得完整的类型安全:
import { defineAuditAction } from 'evlog'
const refund = defineAuditAction('invoice.refund', { target: 'invoice' })
log.audit(refund({
actor: { type: 'user', id: user.id },
target: { id: 'inv_889' }, // 类型推断为 'invoice'
outcome: 'success',
}))
将此与 Schema → Action naming 中的动作字典配合使用。
defineAuditCatalog()
对于不止寥寥几个动作的情况,请将它们分组到一个类型化的 catalog 中,而不是逐个声明 defineAuditAction。与错误 catalog 的约定相同:UPPER_SNAKE_CASE 键、lower.dot.case 前缀,最终的 wire action 为 ${prefix}.${KEY}。
import { defineAuditCatalog } from 'evlog'
export const billingAudit = defineAuditCatalog('billing', {
INVOICE_REFUND: {
target: 'invoice',
severity: 'high',
requiresChanges: true,
description: '将发票退款给客户',
redactPaths: ['cardNumber'],
},
INVOICE_CREATE: { target: 'invoice' },
INVOICE_VOID: { target: 'invoice', severity: 'high', requiresReason: true },
SUBSCRIPTION_CANCEL: { target: 'subscription', severity: 'high' },
})
import { billingAudit } from '~/audit/billing'
log.audit(billingAudit.INVOICE_REFUND({
actor: { type: 'user', id: user.id },
target: { id: 'inv_889' }, // 类型推断为 'invoice'
outcome: 'success',
}))
每个条目都会生成一个围绕 defineAuditAction 的轻量封装(target 类型在定义时固定,action 名称自动加前缀)。catalog 元数据会在每个工厂以及 _actions / _prefix 上暴露:
billingAudit.INVOICE_REFUND.action // 'billing.INVOICE_REFUND'(字面量类型)
billingAudit.INVOICE_REFUND.target // 'invoice'
billingAudit.INVOICE_REFUND.severity // 'high'
billingAudit.INVOICE_REFUND.requiresChanges // true
billingAudit.INVOICE_REFUND.redactPaths // ['cardNumber']
billingAudit._actions // readonly ['billing.INVOICE_REFUND', ...]
| Entry field | Purpose |
|---|---|
target | 在调用处注入的默认 target.type |
description | 面向人类的标签,用于文档、SIEM 规则和审查工具 |
severity | 'low' | 'medium' | 'high' | 'critical' —— 告警和审查优先级 |
requiresChanges | 说明调用方应附加 changes(例如通过 auditDiff) |
requiresReason | 说明调用方应附加 reason(尤其是拒绝场景) |
redactPaths | 该动作上 auditDiff({ redactPaths: [...] }) 的默认路径 |
defineAuditAction vs defineAuditCatalog — 何时选择
二者都会生成相同的调用处工厂形状。按规模选择:
defineAuditAction(action, opts?)——一次性动作,或在超大型仓库中按文件组织。与defineError相呼应。等价于只有单个条目的 catalog,但不做前缀推导:你直接写完整的 wireaction。defineAuditCatalog(prefix, map)——将超出寥寥几个的相关动作归入一个前缀下。与defineErrorCatalog相呼应。wireaction会自动推导为${prefix}.${KEY},catalog 元数据(_actions、_prefix)可用于内省,并且只需一行declare module 'evlog'即可将整个 bundle 暴露到类型化的AuditAction联合类型中。
你可以在同一代码库中混用这两者——把跨领域的一次性动作保留为 defineAuditAction,把有边界的上下文(billing、auth、subscription)分组为 catalogs。
无处不在的类型安全动作(可选)
通过扩展 RegisteredAuditCatalogs 来模仿错误 catalog 的增强:
import type { billingAudit } from './audit/billing'
declare module 'evlog' {
interface RegisteredAuditCatalogs {
billing: typeof billingAudit
}
}
这会在类型化的 AuditAction 导出上呈现所有已注册动作的联合类型,适用于共享辅助函数、仪表盘以及可安全重构的比较逻辑。
auditDiff()
对于会修改数据的操作,请使用 auditDiff() 生成紧凑、支持脱敏感知的 JSON Patch:
auditDiff()。 在做 diff 之前,请移除计算列、哈希密码、内部标志以及大型 JSON blob。changes 的重点是语义上发生了什么变化(状态从 paid → refunded),而不是字节层面发生了什么变化(lastModified 时间戳变了)。冗长嘈杂的 changes 字段会让审计日志最先变得不可读。import { auditDiff } from 'evlog'
const before = await db.users.byId(id)
const after = await db.users.update(id, patch)
log.audit({
action: 'user.update',
actor: { type: 'user', id: actorId },
target: { type: 'user', id },
outcome: 'success',
changes: auditDiff(before, after, { redactPaths: ['password', 'token'] }),
})
{
"audit": {
"action": "user.update",
"actor": { "type": "user", "id": "usr_42" },
"target": { "type": "user", "id": "usr_99" },
"outcome": "success",
"changes": [
{ "op": "replace", "path": "/email", "from": "old@example.com", "to": "new@example.com" },
{ "op": "replace", "path": "/role", "from": "member", "to": "admin" },
{ "op": "replace", "path": "/password", "from": "[REDACTED]", "to": "[REDACTED]" }
],
"version": 1,
"idempotencyKey": "ak_5e7d8f9a0b1c2d3e"
}
}
withAudit() — 自动埋点
开发者经常忘记调用 log.audit()。把函数包装起来,就再也不会漏记:
log.audit()。import { withAudit, AuditDeniedError } from 'evlog'
const refundInvoice = withAudit(
{ action: 'invoice.refund', target: input => ({ type: 'invoice', id: input.id }) },
async (input: { id: string }, ctx) => {
if (!ctx.actor) throw new AuditDeniedError('匿名退款被拒绝')
return await db.invoices.refund(input.id)
},
)
await refundInvoice({ id: 'inv_889' }, {
actor: { type: 'user', id: user.id },
correlationId: requestId,
})
{
"audit": {
"action": "invoice.refund",
"actor": { "type": "user", "id": "usr_42" },
"target": { "type": "invoice", "id": "inv_889" },
"outcome": "success",
"version": 1,
"idempotencyKey": "ak_8f3c4b2a1e5d6f7c",
"correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599"
}
}
{
"level": "error",
"audit": {
"action": "invoice.refund",
"actor": { "type": "user", "id": "usr_42" },
"target": { "type": "invoice", "id": "inv_889" },
"outcome": "failure",
"reason": "Stripe 错误:charge 已退款",
"version": 1,
"idempotencyKey": "ak_4c5d6e7f8a9b0c1d",
"correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599"
},
"error": {
"name": "StripeError",
"message": "charge already refunded",
"stack": "..."
}
}
{
"level": "warn",
"audit": {
"action": "invoice.refund",
"actor": { "type": "system", "id": "anonymous" },
"target": { "type": "invoice", "id": "inv_889" },
"outcome": "denied",
"reason": "匿名退款被拒绝",
"version": 1,
"idempotencyKey": "ak_d12c3a4f5b6e7d8c",
"correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599"
}
}
结果判定:
fn返回成功 →outcome: 'success'。fn抛出AuditDeniedError(或任何status === 403的错误)→outcome: 'denied',错误消息会成为reason。- 其他抛出的错误 →
outcome: 'failure',随后重新抛出。