CLI

CI 中的 evlog map

评分规则
使用 --min-score 根据可观测性分数阻止拉取请求。JSON 契约、退出代码、GitHub Actions 和 jq 配方。

一次看的分数只是一个愉快的午后。CI 中的分数,才是真正阻止下一个处理器在黑暗中上线的东西。

在回归时使构建失败

--min-score <n> 在项目低于阈值时会打印明确的判定并以 1 退出:

Terminal
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 变量)都会使命令停止,而不是被当作“不设置门禁”,因为一个悄悄自我禁用的门禁,会让无人检查的标准报告成功。

这就把分数变成了拉取请求可以推动的东西。一次运行会说应用低于门槛,并指出负责的三个入口点;下一次则会说它已经超过:

evlog map --min-score 80·checks pending
min 80
#128checkout: retry declined cards
queued
#129instrument the three dark handlers
queued
useLogger + log.set on two handlers, log.audit on the third
GATEwaiting for evlog map --min-score 80
机会项 永远不会影响分数,所以门槛绝不可能因为 CLI 建议你采用某个功能而失败。只有要求项才会使构建失败。

棘轮:--baseline

--min-score 问的是“这个应用够好吗”。--baseline 问的是另一个问题:这次拉取请求有没有让它变得更糟

Terminal
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 的情况下重新运行以接受它

单位是需求本身,而不是总分。一次重构如果给一个路由加了埋点,却把另一个路由弄坏了,分数可能完全不变;而只盯着数字的门禁会把它当成没有变化。

不会获取任何内容。 基线是您的代码仓库已经包含的文件,因此 CI 在 checkout 后立即就能在磁盘上获得它:不需要网络、令牌或仓库访问权限。私有仓库和公开仓库的门禁完全相同。

什么算回归

变更结论
某项需求从 pass 变成 fail门禁失败
一个通过的需求被注释禁用门禁失败
全局分数下降了门禁失败
新增了一个没有埋点的入口点列在 NEW AND DARK 下,不会失败
删除了一个入口点记为移除
某项需求从 fail 变成 pass记为修复

故意让禁用检查和破坏检查付出同样代价:禁用注释是用最小成本让门禁变绿、却不写任何埋点的最便宜方式;如果棘轮放它过去,测到的就会是注释而不是代码。

新增的黑暗路由会被报告,而不是触发门禁。对于尚未变绿的应用,如果每个新增端点都会导致拉取请求失败,团队最终学会的就是关闭这个作业。新工作的标准应由 --min-score 设定。如果你想同时实现这两点,就同时运行它们。

map 文件就是棘轮

--baseline 的意思是:您要提交 evlog.map.json,而不是忽略它:

Terminal
evlog map            # 更新文件
git add evlog.map.json

一次报告回归的运行会刻意保持文件不变。如果那时把它重写,就相当于把棘轮往更差的状态往下拨;而同一个命令再运行一次就会报告没有回归并以 0 退出。因此,接受一次下降必须是显式的:在不使用 --baseline 的情况下重新运行,并在提交信息中写明原因,把新 map 一起提交。

.github/workflows/observability.yml
      - uses: actions/checkout@v5
      - run: pnpm install --frozen-lockfile
      - run: pnpm evlog map --baseline

这个文件每次运行还会因为 generatedAt 而产生变动。代价就是要跟踪它,但回报是能拿到一份可审查的 diff,准确显示哪些入口点改变了类别。

也可以改为对比某个分支

传入 git:<ref>,通过 git 而不是工作树读取已提交的副本,这适合修改 map 文件本身的拉取请求:

Terminal
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 中运行

.github/workflows/observability.yml
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 将它固定住:

Terminal
pnpm add -D @evlog/cli
.github/workflows/observability.yml
      - run: pnpm install --frozen-lockfile
      - run: pnpm evlog map --min-score 80 --no-write

这样,分数变化总是由你造成的,而升级 CLI 则是一个独立的拉取请求;在其中,分数变化是目的,而不是意外。

逐步提高,不要一步到顶

如果把阈值设为 90,而应用当前得分只有 41,那么每个拉取请求都会失败,这会让团队学会忽略这个作业。先把它设为当前分数,然后随着你修复问题再逐步提高:

Terminal
# 我们现在是多少?
evlog map --json --no-write | jq '.map.score'

每一个提升分数的拉取请求,都会把底线一起抬高。报告已经告诉你下一步值多少钱:▲ 76 → 86,修复上面列出的 3 项即可

使用 JSON 输出

--json 会将整个 map 写入 stdout。报告会输出到 stderr,因此二者永远不会混在一起。

Terminal
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 下,mapPathnullmap.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 是项目总数。它是需要和分数一起关注的数字:门禁只衡量规则被允许查看的内容。

配方

Terminal
# 分数,用于徽章或评论
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:该文件是构建产物,因此应忽略它:
    .gitignore
    evlog.map.json
    

    --no-write 会让作业完全不生成该文件。
  • 使用 --baseline 设置门禁:像 lockfile 一样跟踪它。使用 evlog map 重新生成,在 diff 中审查它,不要手动编辑。棘轮会与已提交的副本进行比较,而 git:<ref> 只能读取已经提交的文件。

已跟踪的 map 可读,而这是一项优点,不是代价:拉取请求的 diff 会准确显示哪些入口点改变了类别,这是一个值得讨论的内容。代价是每次运行都会产生变动,因为 generatedAt 每次都会变化。

门禁 monorepo 中的每个包

evlog map 一次扫描一个应用。逐个进行门控:

.github/workflows/observability.yml
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

不同的应用可以设置不同的阈值,这通常正是你想要的:处理支付的应用应该比营销网站要求更高。

下一步

  • 规则:失败的检查意味着什么,以及如何修复
  • 评分:门禁所依据的数字是如何计算的