一次看的分数只是一个愉快的午后。CI 中的分数,才是真正阻止下一个处理器在黑暗中上线的东西。
在回归时使构建失败
--min-score <n> 在项目低于阈值时会打印明确的判定并以 1 退出:
evlog map --min-score 80
GATE score 76 is below --min-score 90 — exit code 1
fix what is listed under FIX FIRST to pass · evlog.dev/cli/ci
GATE score 76 meets --min-score 70 — exit code 0
无论哪种情况都会打印完整报告,因此失败的任务会告诉你需要修复什么,而无需再次运行。
阈值必须是 0 到 100 之间的整数。任何其他内容(拼写错误、误加的单位、未展开的 shell 变量)都会使命令停止,而不是被当作“不设置门禁”,因为一个悄悄自我禁用的门禁,会让无人检查的标准报告成功。
这就把分数变成了拉取请求可以推动的东西。一次运行会说应用低于门槛,并指出负责的三个入口点;下一次则会说它已经超过:
棘轮:--baseline
--min-score 问的是“这个应用够好吗”。--baseline 问的是另一个问题:这次拉取请求有没有让它变得更糟。
evlog map --baseline
它会按每个入口点、每个检查项,将最新扫描结果与您提交的 evlog.map.json 进行比较:
BASELINE score 56 → 44 (-12) vs evlog.map.json
REGRESSED
✗ POST /api/checkout — useLogger 不再通过
server/api/checkout.post.ts · evlog.dev/learn/wide-events
✗ POST /api/checkout — log.audit 不再通过
server/api/checkout.post.ts · evlog.dev/use-cases/audit/overview
NEW AND DARK
⚠ GET /api/reports — 新增但没有任何埋点
2 个回归和 12 分的下降 — 退出码 1 · evlog.dev/cli/ci
evlog.map.json 未被重写 — 修复回归,或者在不使用 --baseline 的情况下重新运行以接受它
单位是需求本身,而不是总分。一次重构如果给一个路由加了埋点,却把另一个路由弄坏了,分数可能完全不变;而只盯着数字的门禁会把它当成没有变化。
checkout 后立即就能在磁盘上获得它:不需要网络、令牌或仓库访问权限。私有仓库和公开仓库的门禁完全相同。什么算回归
| 变更 | 结论 |
|---|---|
某项需求从 pass 变成 fail | 门禁失败 |
| 一个通过的需求被注释禁用 | 门禁失败 |
| 全局分数下降了 | 门禁失败 |
| 新增了一个没有埋点的入口点 | 列在 NEW AND DARK 下,不会失败 |
| 删除了一个入口点 | 记为移除 |
某项需求从 fail 变成 pass | 记为修复 |
故意让禁用检查和破坏检查付出同样代价:禁用注释是用最小成本让门禁变绿、却不写任何埋点的最便宜方式;如果棘轮放它过去,测到的就会是注释而不是代码。
新增的黑暗路由会被报告,而不是触发门禁。对于尚未变绿的应用,如果每个新增端点都会导致拉取请求失败,团队最终学会的就是关闭这个作业。新工作的标准应由 --min-score 设定。如果你想同时实现这两点,就同时运行它们。
map 文件就是棘轮
--baseline 的意思是:您要提交 evlog.map.json,而不是忽略它:
evlog map # 更新文件
git add evlog.map.json
一次报告回归的运行会刻意保持文件不变。如果那时把它重写,就相当于把棘轮往更差的状态往下拨;而同一个命令再运行一次就会报告没有回归并以 0 退出。因此,接受一次下降必须是显式的:在不使用 --baseline 的情况下重新运行,并在提交信息中写明原因,把新 map 一起提交。
- uses: actions/checkout@v5
- run: pnpm install --frozen-lockfile
- run: pnpm evlog map --baseline
这个文件每次运行还会因为 generatedAt 而产生变动。代价就是要跟踪它,但回报是能拿到一份可审查的 diff,准确显示哪些入口点改变了类别。
也可以改为对比某个分支
传入 git:<ref>,通过 git 而不是工作树读取已提交的副本,这适合修改 map 文件本身的拉取请求:
evlog map --baseline git:origin/main
evlog map --baseline ../base-map.json # 或任意路径
如果只传了裸的 --baseline,而磁盘上又没有 map,CLI 会自动回退到 git:HEAD,因此答案仍然是“上一个提交怎么说”,而不是“我一分钟前怎么说”。
通过 git 读取只适用于已经提交的文件:被 gitignore 的 map 在任何 ref 中都不存在,CLI 的错误信息会准确说明这一点,并指出修复方法。
当规则集发生变化时
evlog.map.json 会记录写入它的 CLI 版本,以及一个独立的规则集版本;只有规则语义发生变化时,规则集版本才会变化。在 --baseline 模式下,CLI 会将已提交的规则集版本与自身版本进行比较。如果二者不同,它会拒绝执行 diff 并以 2 退出:如果两个版本之间收紧了一条规则,那么拉取请求未触及的代码也会显示为从 pass 变成 fail;而一个归咎错误对象的门禁,还不如一个承认自己无法运行的门禁。
baseline was written by @evlog/cli 0.3.0, running @evlog/cli 0.4.1 (rule set 1 → 2)
regenerate the baseline: evlog map && git add evlog.map.json
发布版本如果加入了功能但没有修改规则,就会保留规则集版本,因此升级 CLI 不会强迫所有人重新生成 map。版本报告功能加入之前写入的 map 没有版本字段;CLI 会将其视为未知并警告一次,而不是在升级时让每个项目都失败。
每个退出代码的含义
| 代码 | 含义 |
|---|---|
0 | 分数达到阈值、相对于基线没有回归,或两者都未请求 |
1 | 分数低于 --min-score、相对于 --baseline 存在回归,或扫描无法运行 |
2 | 用法错误——未知标志、无效的 --framework,或基线的规则集与运行中的 CLI 不匹配 |
$? 替换为其中最后一个命令的退出代码,因此 evlog map --min-score 90 \| tee map.log 总是看起来是成功的。请在该步骤中添加 set -o pipefail。在 GitHub Actions 中运行
name: Observability
on: pull_request
jobs:
map:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
- run: npx @evlog/cli map --min-score 80 --no-write
--no-write 会阻止作业生成一个没人会去看的 evlog.map.json。
锁定版本
这个 CLI 还很年轻:规则仍在不断细化,也会不断加入新规则,所以一次发布就可能改变某段没人动过的代码的判定。在受保护的作业里,这会表现为拉取请求因为作者在差异中看不到的原因而失败。
把 CLI 作为开发依赖安装,并让 lockfile 将它固定住:
pnpm add -D @evlog/cli
- run: pnpm install --frozen-lockfile
- run: pnpm evlog map --min-score 80 --no-write
这样,分数变化总是由你造成的,而升级 CLI 则是一个独立的拉取请求;在其中,分数变化是目的,而不是意外。
逐步提高,不要一步到顶
如果把阈值设为 90,而应用当前得分只有 41,那么每个拉取请求都会失败,这会让团队学会忽略这个作业。先把它设为当前分数,然后随着你修复问题再逐步提高:
# 我们现在是多少?
evlog map --json --no-write | jq '.map.score'
每一个提升分数的拉取请求,都会把底线一起抬高。报告已经告诉你下一步值多少钱:▲ 76 → 86,修复上面列出的 3 项即可。
使用 JSON 输出
--json 会将整个 map 写入 stdout。报告会输出到 stderr,因此二者永远不会混在一起。
evlog map --json --no-write > map.json
{
"schemaVersion": 2,
"environment": "production",
"map": {
"version": 1,
"generatedAt": "2026-07-25T18:42:10.114Z",
"framework": "nuxt",
"projectName": "evlog-playground",
"score": 76,
"routes": []
},
"summary": { "instrumented": 19, "partial": 2, "dark": 8, "exempt": 0, "suppressedChecks": 0 },
"mapPath": "/path/to/app/evlog.map.json"
}
在 --no-write 下,mapPath 为 null。map.routes 中的每一项都长这样:
{
"framework": "nuxt",
"kind": "api",
"method": "POST",
"path": "/api/auth/login",
"file": "server/api/auth/login.post.ts",
"handler": { "line": 1, "column": 0 },
"id": "337325358269",
"checks": {
"wide-event": { "status": "pass" },
"context": { "status": "pass" },
"structured-errors": { "status": "n/a" },
"error-handling": { "status": "n/a" },
"audit": {
"status": "fail",
"message": "有 logger + context 但没有 log.audit() — 敏感路由需要审计轨迹",
"evidence": { "file": "server/api/auth/login.post.ts", "line": 1 }
}
},
"suggestions": {},
"sensitivity": { "level": "high", "reasons": ["auth: 路径中包含 \"auth\""] },
"score": 75
}
checks 包含要求项,suggestions 包含机会项。它们使用独立的键,正是为了让消费者或 CI 脚本永远不会将建议误认为失败。规则 id 是稳定的:注册表和已发布的 id 联合会在构建时相互校验,因此发布版本不会悄悄改变你收到的内容。
某人 通过注释禁用 的检查会显示为 n/a,并带有 "suppressed": true,其 evidence 指向注释而不是处理器:
"wide-event": {
"status": "n/a",
"suppressed": true,
"message": "在第 1 行禁用 — 存活探针,故意保持静默",
"evidence": { "file": "server/api/health.get.ts", "line": 1 }
}
summary.suppressedChecks 是项目总数。它是需要和分数一起关注的数字:门禁只衡量规则被允许查看的内容。
配方
# 分数,用于徽章或评论
evlog map --json --no-write | jq '.map.score'
# 所有完全没有事件的入口点
evlog map --json --no-write \
| jq -r '.map.routes[] | select(.checks["wide-event"].status == "fail") | .file'
# 每个失败项,格式为 file:line — message
evlog map --json --no-write \
| jq -r '.map.routes[].checks | to_entries[] | select(.value.status == "fail")
| "\(.value.evidence.file):\(.value.evidence.line) — \(.value.message)"'
# 当有任何暗点时让脚本失败
test "$(evlog map --json --no-write | jq '.summary.dark')" -eq 0
# 有多少分数来自被禁用的检查
evlog map --json --no-write | jq '.summary.suppressedChecks'
map 文件位于何处
除非传入 --no-write,否则每次运行都会将 evlog.map.json 写入项目根目录。它包含与 --json 相同的数据。是否提交它取决于你的门禁方式:
- 不对 CI 设置门禁,或只使用
--min-score:该文件是构建产物,因此应忽略它:.gitignoreevlog.map.json--no-write会让作业完全不生成该文件。 - 使用
--baseline设置门禁:像 lockfile 一样跟踪它。使用evlog map重新生成,在 diff 中审查它,不要手动编辑。棘轮会与已提交的副本进行比较,而git:<ref>只能读取已经提交的文件。
已跟踪的 map 可读,而这是一项优点,不是代价:拉取请求的 diff 会准确显示哪些入口点改变了类别,这是一个值得讨论的内容。代价是每次运行都会产生变动,因为 generatedAt 每次都会变化。
门禁 monorepo 中的每个包
evlog map 一次扫描一个应用。逐个进行门控:
jobs:
map:
runs-on: ubuntu-latest
strategy:
matrix:
app: [apps/web, apps/admin]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
- run: npx @evlog/cli map --cwd ${{ matrix.app }} --min-score 80 --no-write
不同的应用可以设置不同的阈值,这通常正是你想要的:处理支付的应用应该比营销网站要求更高。