Coverage 由规则引擎检查,而不是一堆启发式规则。每条规则都有稳定的 id、记录在文档中的权重、文档链接,以及它所适用的入口点类型集合;每条发现还会带有其来源文件和行号,因此你始终可以去查看一个裁决的具体依据。
规则分为两类,这一区别很重要:
| 要求 | 建议 | |
|---|---|---|
| 对分数的影响 | 失败时扣分 | 从不影响 |
| 出现时机 | 只要适用就出现 | 仅当项目已经使用该功能时出现 |
| 在报告中 | FIX FIRST 和 THEN | GOING FURTHER |
| 在 JSON 中 | checks | suggestions |
| 能否作为 CI 门禁失败 | 是 | 否 |
三种状态
每条规则都会对一个入口点返回三种裁决之一。
| 状态 | 含义 |
|---|---|
pass | 规则查看后找到了它想要的内容 |
fail | 规则查看后没有找到——如果它是必需项,则会扣除权重 |
n/a | 这个问题在这里没有意义,或者你已禁用它 |
n/a 正在发挥实际作用。一个从不抛出异常的处理器,永远不会被问及其错误是否包含 why 和 fix;一个没有 catch 的处理器,永远不会被问及其捕获是否会记录日志。过去这些都会被默认算作通过,这会导致报告声称某个文件处理了它实际上并没有处理的错误。现在它们在矩阵中只是一个个点,分数则根据实际适用的规则来计算。
要求
六条规则会影响分数。它们适用于类似 handler 的入口点(API handlers、middleware、scheduled jobs 和 server actions),但 fetch 除外,它是唯一一条仅适用于页面的规则。
| 列 | Id | 权重 | 期望 |
|---|---|---|---|
log | wide-event | 40 | useLogger() |
audit | audit | 25 | log.audit() |
err | structured-errors | 20 | createError({ why, fix }) |
fetch | page-error-handling | 20 | fetch 错误处理 |
ctx | context | 15 | log.set() |
catch | error-handling | 15 | 在 catch 中记录日志或重新抛出 |
log:这个入口点是否会发出宽事件?
这是最重要的规则,因为其他一切都依赖它。没有 logger 的入口点不会产生任何事件,而再好的错误处理也无法告诉你内部发生了什么。
通过已解析的 evlog logger(useLogger、createLogger、createRequestLogger、initLogger)或 evlog 包装器(withEvlog、withAudit)即可满足。
消息取决于你的框架。使用 evlog 的 Nitro 插件时,每个请求都会发出一个事件,无论 handler 是否主动请求,所以那里失败的不是“沉默”,而是“空洞”:
handler 未向其请求事件添加任何内容——只记录了方法、路径和状态
没有 useLogger() — handler 是一个黑暗事件
export default defineEventHandler(async (event) => {
const log = useLogger(event)
})
export async function POST(request: Request) {
const log = useLogger()
}
ctx:是否附加了请求上下文?
没有 log.set() 的 logger 产生的事件在技术上是有效的,但它对所描述的请求却什么也没说。这是宽事件在生产中变得无用最常见的方式:你每个请求得到一行日志,但没有一行能回答问题。
只有在已解析的 evlog logger 上调用 set() 才算。无关的 Map.set() 不算。
log.set({ user: { id }, order: { id, total } })
err:抛出的错误是否携带 why 和 fix?
throw new Error('failed') 到达客户端时只是一串字符串,没有原因也没有补救措施。createError({ why, fix }) 才能让错误对凌晨 3 点读它的人来说具有可操作性。
仅当 handler 抛出某些内容时适用:throw 或 createError() 调用。消息会准确指出缺少的内容:
throw new Error() — 使用 createError({ why, fix })
createError() 缺少 why 和 fix
createError() 有 why 但缺少 fix
createError() 如果是返回而不是抛出,也会被检查,因为它仍然会塑造响应。
throw createError({
status: 400,
message: '调用方看到的内容',
why: '实际出了什么问题',
fix: '该怎么处理',
})
audit:这个敏感入口点是否留下了痕迹?
仅当敏感性分类器发现涉及资金或认证时适用。在其他地方,审计记录只会造成噪音,因此规则会报告 n/a,而不是无条件通过。
log.audit() 可以满足该规则,包括通过可选链调用,因此 log.audit?.deny() 也算。
建议的操作会根据路由自动推断,因此 /api/auth/login 会建议 auth.login,而 /api/orders/[id]/refund 会建议 orders.refund:
log.audit({
action: 'auth.login',
actor: { type: 'user', id: user.id },
})
catch:每个捕获的错误是否都被记录日志或重新抛出?
被吞掉的错误比未处理的错误更糟:请求看起来成功了,事件里什么也没说,直到用户报告时故障才会暴露。
当 catch 满足以下任一情况时,就算已处理:记录日志(log.error、log.warn、log.set、log.audit、captureException,或任何 console.*)、重新抛出,或返回。会分别报告两种失败:
empty catch block swallows errors
catch block swallows error without logging or rethrow
完全没有 catch 的 handler 会被标记为 n/a,而不是缺失。evlog 的框架集成会挂接运行时的错误通道,因此逃出你 handler 的异常仍会随着其状态记录在事件中。
catch (error) {
log.error(error)
}
fetch:这个页面在其数据获取失败时还能正常工作吗?
唯一一条页面规则。仅适用于确实在服务端获取数据的页面。纯展示页面没有会失败的内容。
通过 catch、.catch()、onError 处理器,或从 fetch 本身解构出的 error 绑定即可满足。
<script setup lang="ts">
const { data, error } = await useFetch('/api/orders')
if (error.value) log.error(error.value)
</script>
try {
const orders = await getOrders()
} catch (error) {
log.error(error)
}
机会
四条规则建议进一步改进。它们永远不会影响分数,并且只有当项目已经在某处采用了该功能时才会触发。重点是让你选择的功能发挥更大价值,而不是向你推销某些东西。
是否采用是基于 AST 确认的,而不是通过搜索文本,所以在注释里提到的功能不算。
| 列 | Id | 触发条件 | 范围 |
|---|---|---|---|
catalog | error-catalog | 项目声明了一个 catalog,且同一个内联错误出现在两个或更多文件中 | 每个入口点 |
audit+ | audit-coverage | 项目记录审计事件,而此处理程序在没有审计记录的情况下更改了状态 | 每个入口点 |
ai | ai-logging | ai 是一个依赖项,但调用 AI SDK 时没有 evlog/ai | 每个项目一次 |
identity | auth-identity | better-auth 是一个依赖项,但未安装 evlog/better-auth | 每个项目一次 |
catalog:这些重复的错误是否应该成为 catalog 条目?
刻意保持狭窄。早期版本会对任何内联 createError() 触发,这导致本来就有合理错误的处理程序也被提示。重复才是能自证其说的信号:同一个状态和消息如果在三个地方都写着,迟早会产生偏差。
"402 Card declined" 在这里以及另外 2 个文件中都写了出来 — 一个 catalog 条目就能覆盖它们
这个建议会提议使用你已经有的 catalog,而不是凭空新建一个。Catalogs →
audit+:这种状态变更是否也应该记录在审计轨迹中?
这是对 audit 要求的补充,后者只会在资金和认证相关场景触发。这个规则更温和、覆盖面更广:一旦你有了审计轨迹,任何状态变更都值得关注,而你才是最清楚哪些重要的人。它会在没有审计记录的处理程序中寻找 create、update、insert、upsert、delete 或 destroy 调用。已经被 audit 要求覆盖的入口点会被跳过,因此同一个缺口不会被报告两次。记录审计事件 →
ai:模型调用、token 和延迟是否记录在事件中?
当未导入 evlog/ai 时,在 generateText、streamText、generateObject、streamObject、embed 和 embedMany 上触发。如果没有它,事件只会记录请求发生了,却不会记录模型成本,而这通常是请求中最昂贵、变化也最大的部分。整个项目只报告一次,因为一个包装后的模型会服务于所有 handler。AI SDK →
identity:事件是否携带已认证用户的信息?
在实际使用认证的入口点上触发:认证路由本身,或读取 session 的 handler。evlog/better-auth 会将用户和 session 附加到每个事件上,这会把“一个请求失败了”变成“这个用户的请求失败了”。整个项目只报告一次,因为它是一个只需安装一次的插件。Better Auth →
evlog 辅助函数如何被识别
每一条查找 logger 的规则都会问同一个问题:这是 evlog 的 logger 吗? 这个问题是从 AST 中判断的,因此,本地定义的 stub 不会为你加分。
- 从 evlog 导入:
import { useLogger } from 'evlog',包括子路径导入 - 自动导入:在 Nuxt 和 Nitro 中,evlog 模块会注入
useLogger和createEvlogError,因此未导入的调用也算。除非文件声明了自己的同名内容,在这种情况下以本地声明为准 - 从本地模块重新导出:当该模块转发 evlog 的导出时,
import { useLogger } from '@/lib/evlog'也算,这正是Next.js 指南推荐的形式。从工厂函数中解构导出的内容(export const { useLogger } = createEvlog(...))也会被解析 - 通过 evlog 包装器:
withEvlog和withAudit可以为 handler 添加埋点,而无需 handler 自身命名 logger
有两种情况明确不算数:
- 本地 stub。你自己的文件中的
function useLogger() { ... },或从不转发 evlog 导出的./my-logger导入,都不是 evlog 的 logger - evlog 的
log导出。log.info()是简单日志 API。它会发出自己的日志行,而不是为请求的宽事件做贡献,并且没有set或audit。它不满足log列
禁用检查
一个你无法争辩的静态分析器,最终会变成一个你不再运行的静态分析器。当规则错误地判断了你的代码,或者它判断正确但你已经决定不在意时,用注释将它关闭,并放在相关代码旁边:
// evlog-map-disable-next-line wide-event, context -- liveness probe, deliberately silent
export default defineEventHandler(() => ({ ok: true }))
三种形式,与你对 lint 工具的预期一致:
| 指令 | 覆盖范围 |
|---|---|
// evlog-map-disable-next-line <ids> | 注释下方的那一行 |
// evlog-map-disable-line <ids> | 注释所在的那一行,作为行尾注释 |
// evlog-map-disable <ids> | 整个文件,无论它出现在哪里 |
这些 id 就是上方表格中的 id:wide-event、audit、structured-errors、context、error-handling、page-error-handling,以及任何机会 id。多个 id 用逗号或空格分隔。不指定 id 时,指令会覆盖所有规则,这对生成文件或 vendored 文件来说是正确形式,但对于你只是尚未处理的 handler 来说则不合适。块注释的工作方式相同,这正适合放在文件顶部:/* evlog-map-disable -- generated by the SDK */。
-- 后面的内容就是你的理由。它是可选的,但无论如何都值得写:报告里会显示它,因此下一个人读到的是决策,而不是猜测。
被禁用的检查如何影响分数
它会变成附带你所写理由的 n/a,与从未适用的规则状态相同,因此不会扣分,--min-score 门禁也不会再因它失败。
规则仍然会运行。指令豁免的是 发现结果,而不是问题本身:原本会通过的检查仍会以 pass 报告,而从未适用于该 handler 的规则则保持为普通的 n/a。因此,对一个已经埋点的 handler 使用整文件级指令并不会禁用任何东西,而被禁用的检查数量,就是你实际上选择不去看的判定数量。
这意味着,被禁用的检查必须保持可见,否则这个逃生阀就会悄悄变成一种让“什么都不记录”的应用拿到 100 分的方法。它会出现在三个地方:
○ 2 checks disabled by comment in 1 entry point
CHECKS
✓ log.set context attached with log.set()
○ useLogger disabled at line 1 — liveness probe, deliberately silent
在 --all 中,被禁用的单元格显示为 ○,而不是规则不适用时显示的 ·。在 JSON 中,该检查会在其 n/a 旁携带 "suppressed": true,并且 evidence 会指向该注释,因此 CI 作业可以报告绿色分数中有多少被抑制,summary.suppressedChecks 则提供项目总数。
拼写错误会被明确提示
没有任何规则响应的 id 不会静默失效。扫描会在报告上方发出警告,并且该检查会继续失败:
⚠ server/api/probe.get.ts:1 disables "wide-evnt", which is not a check evlog map runs
以为检查已经关闭、但它实际上仍在失败,这比任何一种状态都更糟,因此这一种情况绝不会安静处理。
免除的入口点
evlog 自身的客户端日志采集端点(/api/evlog/ingest 及其相关端点)属于基础设施,而不是应用代码。每条规则都会针对它们返回带有原因说明的 n/a,它们绝不会被列为需要修复的内容,并且在汇总中会将它们计为 exempt,而不是你自己的已埋点入口点。即使文件解析失败,这一点也成立:如果某个豁免只适用于可读文件,那就称不上什么保障。
静态页面出于同样的原因会被豁免。它不获取任何数据,因此 page-error-handling 没有什么需要向它询问,并会报告 n/a。将其计为未观测的入口点,会把营销页面显示为可观测性缺口。
报告错误判定
规则每条都各自对应一个文件,并拥有自己的测试台,因此错误裁决可以在本地修复,而不必进行考古式排查。如果 evlog map <file> 声称某件你能看到的事情并不属实,那就是一个值得提交 issue 的 bug。请附上入口点的输出和 handler,修复就能落在单条规则中。
与此同时,先在那一行禁用检查。一次误报最多只该让你多写一条注释,而不该影响你的 CI 门禁。