作者通过手写参考实现 + 可执行校验器的方式,让 Claude Code 按规范为 47 个 Node.js/Python 服务统一注入 OpenTelemetry,将跨服务故障定位时间从 40 分钟压缩到分钟级,并总结了五点改进反思。
我们有 47 个后端服务,都输出结构化 JSON 日志,但没有分布式追踪。每当一个请求跨越四个服务边界、某处出现延迟时,调试过程就变成了这样:
在边缘服务的日志里找请求 ID。
希望下一个服务也记录了同样的请求 ID。
发现它用的 header 是 x-request-id 而不是 x-correlation-id。
放弃,往上加个 console.log。
我们的中等事故有 40 多分钟都花在"到底是哪个服务慢"上。我手动修复做了个时间盒估算,诚实地算了一下:47 个服务 × 每个约半天(setup、span 规范、配置、冒烟测试)≈ 六周专注工作。没人会批六周的基建活儿。
让这件事变得有意思的约束条件是:追踪埋点是重复的,但不是完全相同的。自动埋点能覆盖 70%,然后每个服务都有自己定制的那 30%——自定义的 HTTP 客户端、队列消费者、根本不是请求入口的 cron 任务。这种组合恰恰是 AI coding agent 的强项,也恰恰是它会悄悄自创规范的地方——如果你放任它的话。
我的第一次尝试是一篇 2000 词的 CONVENTIONS.md,描述 span 应该如何命名、哪些属性是必填的、SDK 怎么接入。Agent 大概遵守了 70%,剩下的全靠即兴发挥——一半服务的属性名不一样,有的用 startSpan 有的用 startActiveSpan。
于是我把那篇文档扔掉,手动仔细地给一个服务做了埋点,让这个服务成为规范:
// tracing.js — 每个其他服务被要求对标的参考实现。
// 在进程启动时尽早 import 以产生副作用。
import { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { resourceFromAttributes } from '@opentelemetry/resources'
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions'
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.SERVICE_NAME,
[ATTR_SERVICE_VERSION]: process.env.GIT_SHA ?? 'dev',
'deployment.environment.name': process.env.APP_ENV ?? 'local',
}),
traceExporter: new OTLPTraceExporter({
url: `${process.env.OTEL_COLLECTOR_URL}/v1/traces`,
}),
instrumentations: [
getNodeAutoInstrumentations({
// 噪音大、零信号,还会让 span 数量翻倍。
'@opentelemetry/instrumentation-fs': { enabled: false },
}),
],
})
sdk.start()
process.on('SIGTERM', () => sdk.shutdown().finally(() => process.exit(0)))
然后是自动埋点做不到的那部分手动埋点——包装一个领域操作:
import { trace, SpanStatusCode } from '@opentelemetry/api'
const tracer = trace.getTracer('billing')
export async function chargeInvoice(invoiceId, amountCents) {
// Span 名称 = "<domain>.<operation>",低基数,,永远不包含 ID。
return tracer.startActiveSpan('billing.charge_invoice', async (span) => {
try {
span.setAttribute('billing.invoice_id', invoiceId)
span.setAttribute('billing.amount_cents', amountCents)
const result = await gateway.charge(invoiceId, amountCents)
span.setAttribute('billing.gateway_status', result.status)
return result
} catch (err) {
span.recordException(err)
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
throw err
} finally {
span.end()
}
})
}
大约 90 行真实可运行、有明确主张的代码。把这个文件交给 agent、告诉它"照这个做,给这个服务加上",效果远比任何文字描述好得多。Agent 会做模式匹配,给它们一个模式。
文字规范是无法执行的,"我觉得看起来对"在 47 个 PR 面前无法扩展。所以我花了半天写了一个验证器,告诉 agent:验证器不通过就不算完成:
# check_tracing.py — 在 CI 中逐服务运行。退出 1 会阻塞 PR。
import re, sys, pathlib
FORBIDDEN_ATTR = re.compile(
r"setAttribute\(\s*['\"](?:.*\b(?:email|user_id|token|url|path|query)\b.*)['\"]"
)
SPAN_NAME = re.compile(r"startActiveSpan\(\s*['\"]([a-z0-9_]+\.[a-z0-9_]+)['\"]")
TEMPLATED_SPAN_NAME = re.compile(r"startActiveSpan\(\s*[`'\"].*\$\{")
def check(service: pathlib.Path) -> list[str]:
errors = []
if not (service / "tracing.js").exists():
errors.append("missing tracing.js bootstrap")
for f in service.rglob("*.js"):
src = f.read_text()
if TEMPLATED_SPAN_NAME.search(src):
errors.append(f"{f}: interpolated span name (cardinality bomb)")
for m in FORBIDDEN_ATTR.finditer(src):
errors.append(f"{f}: high-cardinality or PII attribute: {m.group(0)}")
for m in SPAN_NAME.finditer(src):
if m.group(1).split(".")[0] != service.name.replace("-", "_"):
errors.append(f"{f}: span namespace != service name: {m.group(1)}")
return errors
if __name__ == "__main__":
problems = check(pathlib.Path(sys.argv[1]))
print("\n".join(problems) or "tracing conventions OK")
sys.exit(1 if problems else 0)
这一个文件改变了整个项目的经济模型。在这之前,我审查的是品味;在这之后,我只审查判断决策——验证器在我打开 diff 之前就抓到了所有机械性违规。
一个服务可以通过所有静态检查但仍然什么都不导出,因为 SDK 在 HTTP 框架 import 之后才启动,或者 collector URL 写错了。所以每个服务还有一个冒烟测试:对着本地 collector 启动进程,并断言真实导出的 spans:
// tracing.smoke.test.js
test('http request produces a parented db span', async () => {
await request(app).get('/invoices/42').expect(200)
await flushSpans()
const spans = collector.finished()
const http = spans.find((s) => s.name === 'GET /invoices/:id')
const db = spans.find((s) => s.name.startsWith('pg.query'))
expect(http).toBeDefined()
expect(db?.parentSpanContext?.spanId).toBe(http.spanContext().spanId)
// 路由是模板化的,所以没有 invoice ID 泄露到 span 名称里。
expect(http.name).not.toMatch(/42/)
})
那个父子断言抓住了最常见的真实故障:上下文传播静默断裂,产生 47 条互不相连的单-span"追踪",在列表视图里看起来没问题,但在瀑布图里毫无价值。
flowchart LR
A[Pick next service<br/>grouped by framework] --> B[Agent instruments<br/>against reference impl]
B --> C{check_tracing.py}
C -- fail --> B
C -- pass --> D{smoke test<br/>vs local collector}
D -- fail --> B
D -- pass --> E[Human review:<br/>judgment calls only]
E --> F[PR merged]
F --> A
两个验证器都在 agent 自己的循环里运行,所以大部分迭代都不需要我参与。我的工作是最后一个环节:这个服务自定义的队列消费者是否真的需要一个 span,这个属性是否值得它的存储成本。
九天,47 个服务,每天大约花我 90 分钟注意力。前六个服务用了四天,剩下的 41 个用了五天。
我那篇 2000 词的规范文档产生了大约 70% 的遵守率。一段 90 行的参考文件立刻带来了近乎完美的结构遵守率。如果你发现自己正在写一大段描述代码应该长什么样的文字,停下来去写那段代码——然后指着它说"就这样"。
验证器是这个项目杠杆最高的半天投入。任何你在 prompt 里写"请务必……"的东西,都应该是一个退出非零的脚本。Prompt 是建议;退出码是物理定律。这也意味着规范能脱离你而存在:下一个添加服务的人无需阅读任何文档就能获得同样的约束。
放任不管的话,agent 写出的代码看起来确实合理,比如 startActiveSpan(charge invoice ${invoiceId}) 和 span.setAttribute('user.email', email)。两者都可以用"有描述性"来辩护。两者都是灾难性的——无界的 span 名称会摧毁你后端的索引和成本,而属性里的 PII 是驻留在遥测存储中、伴随整个保留期的合规事件。
Agent 不是不小心,它在优化可读性,因为没有任何东西告诉它去优化基数。这个权衡在 diff 里看不见,在生产环境里却很贵——这恰恰是适合放在 linter 里、而不是 review 评论里的规则类型。
我的原始计划包括"删除现在冗余的日志"。第二天我就把这部分从 agent 的 scope 里删掉了。判断一条日志是否和 span 冗余,需要知道凌晨三点谁会去 grep 它——那是代码库里没有的部落知识,而 agent 自信地删掉 on-call 运行手册里承重的日志行,是个糟糕的取舍。
按信息可用性分割工作,而不是按难度。机械的 90% 交给 agent。需要活在某人脑子里的上下文的那 10%,留给人类。
我按仓库顺序开始,每个服务都要重新建立上下文,token 烧得厉害。按技术栈批量——所有 Fastify,然后所有 Express,然后所有 FastAPI——意味着每个批次复用同一个心智模型、同一堆坑和同一个刚解决的问题。同样工作量,明显更少的 token 和错路。
相邻的任务比打乱顺序的任务便宜。按相似度而不是按便利性排序你的待办列表。
采样策略。我们目前 staging 是 100% 的头部采样,已经很吵了。尾部采样保留每条错误追踪和 5% 的无聊追踪,是显而易见的下一步。
追踪驱动的性能工作。现在有了瀑布图,我想把真实的慢追踪反馈给 agent,作为优化的起点,而不是模糊的"这个端点感觉慢"的 prompt。
自动生成的服务依赖图。追踪里已经编码了真实的调用图,包括没人记得存在的三条调用。自动渲染出来比手工维护架构图强。
这个模式可以泛化到追踪以外任何大规模、重复性、强规范迁移——feature flag SDK、错误上报、认证中间件——都符合同样的结构:一份手写的参考、一个可执行的验证器、一个证明它活着的冒烟测试,然后让 agent 去磨。
如果你正因为"要六周无聊的活儿"而搁置一个"某天再说"的可观测性迁移,它大概已经不是六周了。但杠杆不在 prompt 里——在你开始之前写的参考实现和验证器里。
如果你尝试这个,我真的很想知道你的验证器最终抓到了什么。我抓到了 31 个我本来会合并进去的基数违规。