这是为Sanity Challenge,Path One:Ship an Agent That Queries Real Content 提交的作品
我构建了什么
上个月我的作品赢得了 DEV Summer Bug Smash。那是一个 Go 演示,用于追踪 Redis 命令到 Sentry。本周我对同一个文件运行了一个新工具,它标记出了两个 OpenTelemetry 已经弃用的名称:
attribute。String("db.system", "redis"), // 在 v1.30.0 中重命名为 db.system.name
attribute。String("db.operation", cmd。Name()), // 在 v1.26.0 中重命名为 db.operation.name
我为 OpenTelemetry 做贡献,而我仍然提交了它们。这就是Attrition要解决的问题。
问题所在
OpenTelemetry 的语义约定定义了遥测所使用的名称:http.request.method,db.system.name,http.server.request.duration。这些名称会在不同版本之间变化。旧教程、SDK 常量和复制粘贴的代码片段让已弃用的名称继续存活,而使用它们时不会有任何明显的报错。相反,仪表盘和告警会悄无声息地停止匹配,团队几周后才发现。
查找这些名称听起来像是一个搜索问题。但并不是。正确答案往往取决于关键词搜索无法看到的上下文:
| 你代码中的名称 | 为什么搜索会出错 | Attrition 给出的答案 |
|---|---|---|
net.peer.name |
有两个正确的替代项 |
server.address在客户端 span 上,client.address在服务端 span 上,由你的代码决定 |
http.server.duration |
它从注册表中消失了,且没有弃用条目 |
http.server.request.duration,单位从 ms 变为 s |
db.client.connections.wait_time |
它看起来像是一次简单的重命名 | 单位也从 ms 变为 s,所以仅重命名会记录错误的值 |
cloud.platform = "azure_vm" |
键本身是当前的 | 值已弃用;请使用"azure.vm"
|
db.name |
注册表和迁移指南不一致 | 两种视图都会显示,变更留给人工决定 |
Attrition 做什么
Attrition 读取插桩代码,并对照规范中来自 26 个版本的 1,563 个名称,检查每一个属性键、指标名称、事件名称和枚举值。对于每个已弃用的名称,它会报告:从结构化数据中读取的判定,绝不靠模型猜测适合你代码所用 span 类型的替代项
- 弃用它的版本,以及你固定的版本是否已经弃用了它
- 任何单位变更,当重命名的指标同时改变了单位时
- 官方迁移指南补充的内容,读取自 Sanity Knowledge Base
- 指向规范中说明这一点的确切行的链接
- what the official migration guide adds, read from a Sanity Knowledge Base
- a link to the exact line of the specification that says so
- 一个补丁,仅限于可安全自动化的重命名
演示
在线应用,无需登录: https://attrition-otel.vercel.app
两分钟即可试用
- 打开应用并点击结账服务。一个 TypeScript 文件同时充当 HTTP 服务器和 HTTP 客户端。请注意
net.peer.name在每个 span 上收到不同的答案,并且有两个 HTTP 指标被标记出来,尽管注册表中已不再提及它们。 - 点击订单和一次 LLM 调用。这个 Python 示例展示了一个单位发生变化的指标、一个已弃用的枚举值、一个变成 span 属性的聊天事件,以及注册表和迁移指南不一致的情况。
- 滚动到这个答案是如何构建的位于任何结果下方。它列出了每一次 Sanity Context 调用、发送的查询、耗时以及返回的文档数量。
- 向聊天智能体提问:“我们仍然以毫秒为单位记录 http.server.duration。现在应该用什么?”
- 打开决策页面查看知识库提出的冲突以及每个冲突是如何解决的。
聊天智能体从相同的两个来源作答,并在每条回复下方展示其依据:
项目页面:https://vignesh2027.github.io/attrition
代码
vignesh2027/attrition
找出规范中已经弃用的 OpenTelemetry 属性名称。一个基于 Sanity Context 的智能体,覆盖全部 940 个语义约定属性。
Attrition
找出规范中已经弃用的 OpenTelemetry 名称。
粘贴插桩代码。Attrition 会找出其中的每一个属性键、指标名称、事件名称、枚举值和 SDK 常量,逐一对照来自 26 个 OpenTelemetry 语义约定版本的 1,563 个名称进行检查,并告诉你应该改用什么:针对你的 span 类型、弃用它的版本、说明这一点的确切规范行,以及官方迁移指南补充的内容。它只为可安全自动化的重命名编写补丁,其余留给人工处理。
| 在线应用 |
https://attrition-otel.vercel.app(无需登录) |
| 知识库决策 | https://attrition-otel.vercel.app/decisions |
| 项目页面 | https://vignesh2027.github.io/attrition |
| Sanity 项目 ID |
y9raau23,数据集production(公开) |
| 公开数据集查询 | 所有已弃用名称的 JSON |
| Sanity Studio | https://attrition.sanity.studio |
| Context MCP,数据集 | https://api.sanity.io/v1/context/organizations/oc2g3x7ee/mcp/attrition |
| Context MCP、知识库 | https://api.sanity.io/v1/context/organizations/oc2g3x7ee/mcp/attrition-kb |
为什么这需要结构化内容
语义约定会在版本之间重命名内容,而旧的博客文章、SDK……
一个数据集服务四个客户端,每个客户端都为不同的任务而构建:
| 客户端 | 语言 | 最适合 |
|---|---|---|
| Web 应用 | TypeScript、Next.js | 粘贴文件、阅读证据、提问 |
cli/ |
Go | 扫描整个代码仓库并作为 CI 的门禁;-fix仅应用安全的重新命名 |
rust/ |
Rust | 用于 pre-commit 钩子和气隙 CI 的离线扫描 |
python/ |
Python | 一个基于官方 Python MCP SDK 构建的终端智能体 |
action.yml |
GitHub Action | 让引入已弃用名称的拉取请求失败 |
Rust 扫描器是 Web 提取器的移植版本,一个一致性脚本确认两者在五个真实代码仓库上产生完全相同的计数。CI 会构建并测试全部三种语言,并针对该仓库自身的样本运行该 Action。
我如何使用 Sanity
该设计基于一条规则:结构化数据决定判定结果,知识库解释变更,而模型从不决定什么替换了什么。
1. 将规范建模为结构化内容
该规范以 YAML 形式发布,分散在多个文件和版本中。一个确定性导入器读取从 v1.21.0 到 v1.44.0 的每个带标签版本,并写入1,654 个文档到 Sanity:940attribute、571metric和 36event个文档,外加命名空间和版本统计信息。
最新版本定义了每个名称。较旧的版本补充了任何单个版本都不包含的历史:introducedIn、deprecatedIn、和50 个从注册表中消失且没有任何弃用条目的名称。这些名称以判定结果记录DROPPED以及定义它们的最后一个版本。
每个已弃用的名称都会获得一个判定结果,该结果在代码中根据规范自身的reason、renamed_to和note字段计算得出:
| 判定结果 | 属性 | 指标 | 事件 | 示例 |
|---|---|---|---|---|
RENAMED |
97 | 71 | 1 |
http.method变为http.request.method
|
REPLACED |
15 | 5 | 5 |
rpc.client.duration变为rpc.client.call.duration,ms 到 s |
MOVED_OUT |
58 | 11 | 3 |
gen_ai.*在 v1.42.0 中移至单独的代码仓库 |
DROPPED |
16 | 30 | 4 |
http.server.duration,最后定义于 v1.21.0 |
REMOVED |
21 | 10 | 1 | 无替代项;删除它 |
SPLIT |
4 |
http.target变为url.path加上url.query
|
||
MERGED_INTO |
4 |
code.function并入code.function.name
|
||
SPAN_KIND_DEPENDENT |
3 |
net.peer.name,net.peer.port
|
||
CONDITIONAL |
2 |
db.sql.table,http.host
|
||
USE_SIGNAL_FIELD |
2 |
event.name移至日志记录的 EventName 字段 |
该结构承载了使答案正确的细节。每个替代项都记录了它适用的条件(always、client spans、server spans、together)。二十四个枚举值带有各自的replacementValue。当被重命名指标的替代项使用不同的单位时,导入器会记录一个unitChange通过比较两份指标文档。
那次比较还发现了上游规范中的一个真实错误:两个已弃用system.linux.memory.*条目带有网络数据包计数器的描述和单位。与其报告一个虚假的单位变更,Attrition 将该发现存储为一个specErratum。
我逐条人工审查了每个判定,没有任何名称仍处于NEEDS_REVIEW状态。
2. 通过 Sanity Context 查询数据集
该数据集由一个组织级 Context MCP 端点提供服务,名为attrition。其 GROQ 过滤器限制对约定类型的访问,其指令解释了每个判定的含义,并禁止编造替代项。
一次扫描会发送单个groq_query覆盖代码中发现的每个名称:
{
"release": *[_type == "specRelease"][0].tag,
"attrs": *[_type in ["attribute", "metric", "event"] && key in ["http.method", "net.peer.name", "http.server.duration"]]{
_id, _type, key, status, lastSeenIn, unit, specErratum, "source": source{url, release},
"deprecatedMembers": members[defined(deprecated)]{value, deprecated, replacementValue},
deprecation{verdict, note, deprecatedIn, valueChanges, unitChange{from, to}, movedTo{label, url},
"replacements": replacements[]{key, when, "stability": coalesce(attribute->stability, metric->stability)}}
}
}
代码决定 span 类型,数据决定答案。由于这条路径中没有模型参与,扫描无法编造名称。
3. 为注册表遗漏的内容构建知识库
注册表记录了一个名称变成了什么。它不记录其他哪些内容发生了变化,而对于被删除的名称,它什么都不记录。这些知识存在于官方迁移指南中。
我完全使用 Sanity CLI(sanity context create、context imports create和context build)构建了知识库,来源为144 个来源,在 150 个文档的预算之内:
- 137 个文档从数据集绑定:已弃用的属性,以及大多数插桩使用的 HTTP、数据库、RPC 和消息传递指标
- 5 份官方迁移指南:HTTP、数据库、RPC、代码属性和版本选择
- 2 个较旧的规范页面仍在使用已弃用的名称
第二个组织端点,attrition-kb,为知识库提供服务。对于每个已弃用的名称,扫描会调用knowledge_base_search,然后对结果调用knowledge_base_read,保留讨论该名称的指南条目。条目路径从不硬编码,因为每次重建都可能重命名它们。
一些最有价值的答案正来自这里:
-
http.server.duration映射到http.server.request.duration,单位从 ms 变为 s。注册表对此完全没有记录。 -
db.query.text,是db.statement的替代项,“只有在清理排除了敏感信息时,才应默认收集。”仅靠重命名永远不会暴露出这一隐私要求。 - 每个区域的双发设置,例如
OTEL_SEMCONV_STABILITY_OPT_IN=database/dup,它让团队在迁移期间可以同时发出旧名称和新名称。
4. 解决来源之间的冲突
知识库构建提出了四个真实冲突我的数据集显示http.server.duration以及http.resend_count从注册表中移除且没有替代项,而官方指南称它们被重命名了。
这两种说法在不同层面上都是准确的。注册表一旦某个名称消失就不再列出它,而指南则记录了它去了哪里。我以指南为准解决了每一处冲突,使用client.context.issues.resolve(),用issues.apply()应用了由此产生的页面更新,Sanity 将每个决定转化为一条常驻指令,此后每次构建都遵循它。
效果立竿见影。在这些决定之前,询问http.resend_count会返回“无替代项”。之后,重写后的条目正确地返回了http.request.resend_count。每一处冲突、双方的主张以及所选择的一方都发布在 Decisions 页面上。
5. 三个智能体,一个事实来源
-
扫描器是完全确定性的。提取在代码中进行,判定来自
groq_query,而指导来自knowledge_base_search和knowledge_base_read。 - 聊天智能体首先通过相同的 Context 调用检索其证据,然后在一次受限于该证据的模型调用中作答。它会引用数据集或确切的 Knowledge Base 条目,并在每个答案下方列出所进行的调用。这种设计还使其保持在免费模型层级的限制之内。
-
Python 智能体采用常规路线:模型直接通过官方 Python MCP SDK 调用
groq_query、knowledge_base_search和knowledge_base_read。它证明了相同的端点可以在任何智能体框架中工作。
6. 在真实代码上的结果
我在固定提交上对五个开源仓库运行了 Go CLI,Rust 离线扫描器复现了每一个计数:
| 仓库 | 代码中的已弃用名称 | 需更新的文件 |
|---|---|---|
| open-telemetry/opentelemetry-demo | 6 | 4 |
| jaegertracing/jaeger | 67 | 13 |
| redis/go-redis | 16 | 4 |
| go-gorm/opentelemetry | 0 | 0 |
| uptrace/opentelemetry-go-extra | 18 | 7 |
最重要的发现:go-redis 的官方redisotel包仍然注册旧的db.client.connections.*池指标,并以毫秒记录create_time和use_time。将它们重命名为新名称而不转换数值,会使每个读数都大一千倍。这正是 Attrition 从不对单位发生变化的名称进行自动补丁的原因。
对于go-gorm/opentelemetry的干净结果同样重要:一个可靠的工具在代码是最新的时候必须什么都不说。每个发现都链接到其在bench/RESULTS.md中的源代码行。
设计决策与局限
- 补丁在设计上是保守的。仅对字符串键的无条件重命名进行补丁。Span 类型的选择、单位变更、合并以及来源之间的分歧始终留给人处理。
- 一个发现并不总是 bug。读取历史数据的测试夹具,或在迁移期间有意同时输出新旧名称的库,可能会故意保留一个已弃用的名称。
- 后续步骤:将固定的 SDK 版本映射到它们所发布的确切规范版本,并在 150 份文档限制允许的情况下,将剩余的迁移指南添加到知识库。
Sanity 项目详情
-
项目 ID:
y9raau23 -
数据集:
production(公开) - 公开数据集查询: 每个已弃用名称以 JSON 表示
- Sanity Studio: https://attrition.sanity.studio
-
知识库:
kbI5ncVyqDpc,由组织 Context MCP 端点提供服务attrition-kb -
数据集 Context MCP 端点:
attrition - 知识库决策: https://attrition-otel.vercel.app/decisions







