基于OpenTelemetry的Claude Code按用户成本看板实现

基于OpenTelemetry的Claude Code按用户成本看板实现 如果你们团队最近开始用 Claude Code却没人能回答“这个月 AI 编程到底花了多少钱、花在谁身上”那这篇文章值得读完。Hacker News 上出现了一个叫 Aimeterly 的项目标题很直白Per-user cost dashboards from Claude Codes built-in OTel。它的做法是把 Claude Code 内置的 OpenTelemetry 遥测数据接上成本计算变成一张按用户拆分的成本看板。这个方向比“又一个 AI Agent 工具介绍”重要得多。AI 编程助手不是传统软件那样买断或订阅后成本固定而是每一次对话、每一次自动补全、每一次测试修复都在消耗 token。当这种消耗从一个人变成十个人、一个部门时成本就从一个可忽略的数字变成了必须向财务解释的严肃问题。真正值钱的不是某个看板的界面而是 Claude Code 把全链路遥测数据以标准协议暴露出来这件事本身。本文不打算只做项目速览而是把 Aimeterly 这类工具的底层链路完整拆开OTel 是什么、Claude Code 内置遥测为什么是分水岭、如何从一条 span 推导到按用户成本以及如何用几十行 Python 自己跑通一个最小 Cost Dashboard。读完你得到的不是一个依赖特定项目的结论而是一套可以立刻评估和复用的工程思路。1. AI 编程 Agent 的成本为什么需要“按用户”统计先想一个问题传统开发工具的成本是相对透明的。IDE 许可证多少钱、服务器多少钱、数据库多少钱采购单上写得很清楚。但 AI 编程 Agent 的成本模型完全不同它没有“按年付费”而是“按 token 消耗付费”。token 消耗又和每个人的使用习惯强相关有人只在写复杂算法时打开 Agent有人把日常所有小改动都喂给模型还有人习惯让模型反复重写同一个文件。结果是同岗位、同级别的人对预算的消耗可能差出数倍。没有按用户维度的数据时团队只能面对几类典型困境。第一类是月底账单来了财务问“这个月为什么多了几万美元”技术负责人回答不上来。第二类是想要做预算管控但不知道限制谁、限制什么维度只能一刀切降低所有人使用频率反而压制了合理的使用。第三类是想评估“AI 编程工具到底值不值”但缺少“谁在用、用了多少、产出什么”的基础统计ROI 论证只能靠感觉。按用户成本看板解决的不是“记账”这个小问题它解决的是 AI 编程工具从“个人尝鲜”走向“团队基础设施”过程中最尴尬的断档。只有先建立“成本可解释”这层地基后续的预算、配额、告警、模型路由优化才有依据。Aimeterly 选择的切入点是准确的Claude Code 本身已经通过内置 OTel 把结构化数据做出来了缺的只是把数据变成业务可读的成本语言。这也引出本文的核心判断AI 编程工具的使用成本不是靠行政命令管出来的而是靠数据量化出来、再反馈到决策流程里。按用户统计是第一步也是最关键的一步。2. 核心概念Claude Code 内置 OTel 到底给了我们什么OpenTelemetry 是目前可观测性领域事实上的标准它统一了 Trace、Metric、Log 三类遥测数据的产生、传输和采集方式。你不需要看它的完整规范只要理解它的几个核心概念Service 代表一个服务Trace 代表一次完整请求或会话Span 代表其中的一个操作单元Attribute 是挂在 Span 或 Resource 上的键值对属性。发给后端的协议叫 OTLPOpenTelemetry Protocol采集端一般用 OpenTelemetry Collector 接收再转发到存储与分析平台。Claude Code 内置 OTel 的意义在于它把“一次 AI 编程会话”这种过去很难观测的对象变成了标准的 Trace 数据。一次会话里模型调用、工具执行、文件读写、用户交互都可能以 Span 的形式出现并且附带了 token、model、耗时等关键属性。过去你想知道一次对话用了多少 token只能靠肉眼读客户端日志或者用正则表达式去匹配 stdout这是脆弱且不可维护的。现在数据以标准协议流出来你接一个 Collector 就能开始分析。要特别提醒的是Claude Code 不同版本的 OTel 导出开关、默认字段、启用方式可能存在差异。本文给出的环境变量基于 OTel 标准具体是否能直接被 Claude Code 识别请以你当前版本的官方文档为准。一个稳妥的做法是先启动本地 Collector再开启 Claude Code观察后端是否收到数据收到了就说明当前版本支持收不到再查文档这样比背某个历史配置命令可靠得多。我整理了一个传统日志方式与 OTel 方式的对比方便你理解为什么这个变化是架构级的。维度传统日志解析Claude Code 内置 OTel数据来源抓取客户端 stdout、解析文本应用内部产生的标准化 Trace 数据可靠性格式一改就失效无法覆盖所有输出结构化字段语义稳定不易被输出文本影响会话关联缺少唯一 Trace ID难以还原完整会话天然有 Trace ID / Span ID可还原调用链成本属性往往拿不到 token 和模型信息Span Attribute 中可以携带模型、token 等属性接入成本需要维护解析脚本随版本不断修接入标准 OTLP Collector一次配置即可3. 从一条 Span 到一张按用户成本看板现在我们把链路完整拉通。假设你已经开启 Claude Code 的 OTel 导出数据的流向是这样的Claude Code 进程产生 Trace通过 OTLP 协议发送到 OpenTelemetry CollectorCollector 负责接收、校验、批量处理和转发然后写入文件、本地数据库或云上可观测平台。之后你写一个聚合服务读取这些数据把“用户”“模型”“token 用量”“单价”组合起来最终渲染成一张看板。这条链路里最需要理解的是 Span 的角色。一个 Span 可以是一次模型调用也可以是一次工具执行。它记录了该操作在什么时候开始、什么时候结束、调用了什么模型、输入和输出各有多少 token、这个 span 归属于哪个会话。只要这些字段里能够识别出“用户”维度成本就可以按用户汇总。用户维度的识别是实现中最容易出问题的地方。Claude Code 的 OTel 导出并不保证一定有一个统一叫user_id的字段它可能藏在 Session 属性里也可能需要从组织名、工作区名、API Key 或客户端账号信息中推导。Aimeterly 这类项目本质上做了一件重要工作把不同来源、不同命名的用户字段归一化到统一的用户模型上。自己实现时建议也建一层“字段映射”在数据入口就把原始属性转换成user_id、organization、model、input_tokens、output_tokens这几个标准字段。成本计算本身不复杂核心公式是估算成本 输入 token 数 / 1,000,000 × 模型输入单价 输出 token 数 / 1,000,000 × 模型输出单价不同模型的输入输出单价不同这个配置必须外置不能硬编码在代码里。真正的工程难点在于几个副问题如果某个 Span 缺少用户字段怎么归档到 unknown同一个用户可能在不同 Session 里使用不同模型怎么展示价格表更新后历史数据要不要重算。这些问题虽然琐碎但决定了看板能不能被业务团队真正信任。4. 环境准备先搭一个能接收 OTLP 数据的最小后端要跑通完整链路第一步是准备一个能接收 OTLP 数据的后端。不需要一开始就上云上平台本地用 OpenTelemetry Collector 就可以。它既能接收 Claude Code 发来的数据也能把数据写入本地文件非常适合调试。建议的目录结构如下claude-code-cost-demo/ ├── config/ │ └── otel-collector.yaml ├── mock_otel_traces.json ├── cost_dashboard.py └── output/ └── dashboard.htmlOpenTelemetry Collector 的配置文件重点是接收器和导出器。下面这个配置同时开启了 gRPC 和 HTTP 两种 OTLP 接收方式并配置了 debug 导出器和 file 导出器前者方便在控制台实时看到数据后者把原始数据落盘供后续分析。# 文件路径config/otel-collector.yaml receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: batch: timeout: 5s exporters: debug: verbosity: basic file: path: ./traces.json rotation: max_megabytes: 100 max_days: 7 service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [debug, file]启动 Collector 有两种常见方式。如果是二进制方式直接执行otelcol --config config/otel-collector.yaml如果使用 Docker推荐使用 contrib 镜像因为 file exporter 通常在 contrib 版本中自带docker run --rm \ -v $PWD/config:/etc/otel/config.yaml \ -p 4317:4317 -p 4318:4318 \ otel/opentelemetry-collector-contrib \ --config /etc/otel/config.yaml启动后控制台会进入等待状态。接下来你要做的是在 Claude Code 运行环境中配置 OTLP 导出地址。下面是一组基于 OTel 标准的环境变量适合大多数支持标准协议导出的客户端具体是否能被 Claude Code 识别请以当前版本官方文档为准。export OTEL_SERVICE_NAMEclaude-code export OTEL_EXPORTER_OTLP_ENDPOINThttp://127.0.0.1:4318 export OTEL_EXPORTER_OTLP_PROTOCOLhttp/protobuf export OTEL_METRICS_EXPORTERotlp claude这里使用http/protobuf协议时Collector 的 HTTP 接收端口 4318 就是目标地址。如果使用 gRPC 协议则通常指向 4317。配置完成后随便让 Claude Code 执行一个简单任务然后回到 Collector 的 debug exporter 输出如果能看见span相关日志说明链路已经通了一半。不要忽视一个风险点OTel Trace 数据很可能包含你输入给模型的完整代码片段、Prompt 甚至业务数据。在本地调试没问题但一旦要接入云端可观测平台务必确认脱敏、加密和访问控制策略。成本数据同样是敏感数据不能随意共享。5. 完整示例Python 实现最小按用户成本看板在没有真实 Claude Code 数据时可以用一份模拟的 Span 数据先跑通聚合逻辑。我将给出三部分内容模拟数据、Python 解析脚本、HTML 看板生成。先看模拟数据文件它模拟了多个用户的会话记录字段名和真实 OTel 导出可能存在差异这里仅演示结构。[ { trace_id: trace-1001, span_id: span-1001-1, service: claude-code, timestamp: 2025-06-01T09:10:00Z, attributes: { user_id: zhangsan, organization: platform, session_id: session-001, model: model-a, input_tokens: 32000, output_tokens: 4100 } }, { trace_id: trace-1001, span_id: span-1001-2, service: claude-code, timestamp: 2025-06-01T09:15:00Z, attributes: { user_id: zhangsan, organization: platform, session_id: session-002, model: model-a, input_tokens: 15000, output_tokens: 3000 } }, { trace_id: trace-1002, span_id: span-1002-1, service: claude-code, timestamp: 2025-06-01T10:00:00Z, attributes: { user_id: lisi, organization: search, session_id: session-003, model: model-b, input_tokens: 28000, output_tokens: 5200 } } ]接下来是核心脚本。它读入 Span 数组按用户维度聚合会话数、Token 总量、调用次数和估算成本最后渲染一个自包含的 HTML 页面。脚本中所有价格都是示例你需要按实际模型单价维护一份配置。#!/usr/bin/env python3 cost_dashboard.py - 将 OTel 导出的 Span 数据按用户维度聚合为成本看板。 用法python cost_dashboard.py --input mock_otel_traces.json --output dashboard.html import argparse import json from collections import defaultdict from datetime import datetime # 模型单价配置实际使用时请从 YAML、配置中心或环境变量注入 PRICE_PER_MILLION { model-a: {input: 3.0, output: 15.0}, model-b: {input: 2.5, output: 12.0}, } DEFAULT_PRICE {input: 3.0, output: 15.0} def load_spans(path: str) - list: with open(path, r, encodingutf-8) as f: return json.load(f) def cost_for(model: str, input_tokens: int, output_tokens: int) - float: price PRICE_PER_MILLION.get(model, DEFAULT_PRICE) input_cost input_tokens / 1_000_000 * price[input] output_cost output_tokens / 1_000_000 * price[output] return input_cost output_cost def aggregate(spans: list) - dict: users defaultdict(lambda: { sessions: set(), spans: 0, input_tokens: 0, output_tokens: 0, cost: 0.0, models: defaultdict(int), }) for span in spans: attrs span.get(attributes, {}) user attrs.get(user_id, unknown) model attrs.get(model, unknown) input_tokens int(attrs.get(input_tokens, 0)) output_tokens int(attrs.get(output_tokens, 0)) session attrs.get(session_id, unknown) u users[user] u[sessions].add(session) u[spans] 1 u[input_tokens] input_tokens u[output_tokens] output_tokens u[cost] cost_for(model, input_tokens, output_tokens) u[models][model] 1 return users def render_html(users: dict) - str: rows [] total_cost 0.0 for user, data in sorted(users.items(), keylambda item: item[1][cost], reverseTrue): total_cost data[cost] model_summary , .join( f{model}({count}) for model, count in data[models].items() ) rows.append(ftr td{user}/td td{len(data[sessions])}/td td{data[spans]}/td td{data[input_tokens]:,}/td td{data[output_tokens]:,}/td td{data[cost]:.2f}/td td{model_summary}/td /tr) return f!DOCTYPE html html langzh-CN head meta charsetutf-8 titleClaude Code 按用户成本看板/title style body {{ font-family: Arial, PingFang SC, sans-serif; margin: 40px auto; max-width: 960px; }} table {{ border-collapse: collapse; width: 100%; }} th, td {{ border: 1px solid #ddd; padding: 8px; text-align: right; }} th {{ background: #f5f5f5; }} tr:hover {{ background: #fafafa; }} /style /head body h1Claude Code 按用户成本看板/h1 p生成时间{datetime.now().isoformat(timespecseconds)}/p table theadtrth用户/thth会话数/ththSpan 数/thth输入 Token/thth输出 Token/thth估算成本(USD)/thth模型分布/th/tr/thead tbody {.join(rows)} /tbody /table h2合计${total_cost:.2f}/h2 /body /html def main() - None: parser argparse.ArgumentParser(descriptionClaude Code 按用户成本看板) parser.add_argument(--input, defaultmock_otel_traces.json) parser.add_argument(--output, defaultdashboard.html) args parser.parse_args() spans load_spans(args.input) users aggregate(spans) html render_html(users) with open(args.output, w, encodingutf-8) as f: f.write(html) total sum(u[cost] for u in users.values()) print(f已生成 {args.output}) print(f合计成本${total:.2f}) if __name__ __main__: main()这段脚本里最值得注意的两个逻辑点是成本计算函数cost_for做了输入和输出的分别计价因为大模型 API 的输入输出价格往往差异很大聚合函数aggregate使用defaultdict自动处理未出现过的用户避免空值报错。unknown用户被独立列出这是有意的设计因为 unknown 比例过高通常说明字段映射有问题。运行方式是python cost_dashboard.py --input mock_otel_traces.json --output dashboard.html运行成功后脚本会在终端打印生成的文件名和合计成本同时生成一个可直接在浏览器打开的 HTML 文件。这个示例离一个生产级看板还有距离但核心链路已经完全跑通接收结构化数据、归一化用户维度、按模型计算成本、输出结果。6. 运行结果与效果验证用上面提供的模拟数据运行后预期的聚合结果应该是这样的。用户会话数Span 数输入 Token输出 Token估算成本(USD)模型分布zhangsan2247,0007,1000.25model-a(2)lisi1128,0005,2000.13model-b(1)合计成本约为 0.38 美元。你可以用这个结果验证脚本是否计算正确。第一检查 Token 数字是否和输入 JSON 一致第二核对乘法计算是否精确第三观察用户归类是否正确。如果每个用户的会话数和 Expect 不匹配优先检查 Span 中重复的 trace_id 和 session_id。验证通过后建议做两个额外测试来模拟真实环境。第一个是有缺失字段的数据把某个 Span 的user_id删掉确认它能进入 unknown 分组且不报错。第二个是模型单价缺失的情况把model-a从价格配置里删掉确认脚本会使用DEFAULT_PRICE兜底。这两个测试都通过说明脚本对真实数据的鲁棒性足够。在真实 Claude Code 场景中效果验证的核心标准不是“有没有数据”而是“数据是否可信”。可以比对看板统计的 Token 总量与模型网关后台的用量明细误差应该在合理范围内。如果误差较大优先排查是否有多个导出通道重复计费或者 Collector 的 file exporter 和多级转发之间是否存在重复写盘。7. 常见问题与排查思路在做这套成本采集链路时团队最容易踩的问题集中在数据接收、字段解析、费用计算三个方面。这里我整理了一张排查表基本覆盖了从部署到使用的常见故障。问题现象可能原因排查方式解决方案Collector 收不到任何 TraceOTLP endpoint 地址或协议配置错误查看 Collector debug exporter 日志确认监听端口是否正常核对 endpoint 和 protocolhttp/protobuf 走 4318gRPC 走 4317Claude Code 启动后没有导出行为当前版本未开启 OTel 支持或开关不同查阅当前版本官方文档确认遥测开启方式按官方配置开启验证后再进入后续步骤数据里没有 user_id 字段Claude Code 导出字段名与模拟结构不一致导出一段原始 Trace JSON检查属性命名在脚本入口加字段映射统一转换到标准字段成本与模型网关后台账单不一致价格配置过时或存在额外计费项对比网关明细与看板 Token 总量将价格表外置设置定期更新定期核对 Token 总量同一用户被拆成多个条目用户字段在不同会话中命名不统一检查原始数据里的账号与组织字段建立用户别名映射以统一业务标识为准数据量快速膨胀全量 Trace 长期保留缺少采样与清理查看 Collector file exporter 轮转配置开启按需采样、设置保留天数、定时清理历史文件另一个值得注意的坑是模型接入方式。如果团队把 Claude Code 接入了第三方网关、代理或本地模型服务那么“实际费用”发生在网关或模型服务端而不是 Claude Code 的 OTel 输出中。此时 Claude Code 侧导出的 Token 数据适合做行为分析但费用计算应以网关侧账单为准。两者要配合使用不能互相代替。8. 最佳实践与工程建议基于上面的实现和踩坑经验我把生产环境落地时需要重点关注的工程问题总结为五点。第一用户维度归一化要提前设计。不要假设 OTel 数据里天然有一个干净的 user 字段。团队通常有企业 SSO、个人账号、临时账号、共享账号等不同登录方式建议在数据入口维护一份“登录身份 → 业务用户”的映射表并且把映射规则集中管理。否则看板做得再漂亮用户字段对不上业务方也无法信任。第二价格配置必须外置且可回溯。模型价格会调整不同模型、不同计费模式按量、包周期、折扣差异很大。建议把价格表放在 YAML 或配置中心里每次更新都留版本记录。看板上最好展示“估算成本”而不是“绝对账单”并明确说明这是基于 token 量的估算最终应以服务商账单为准。第三Trace 数据中包含敏感信息。Span 的属性、事件详情甚至某些嵌套资源可能包含代码片段、Prompt 原文、文件路径这些都是企业内部敏感数据。接入云上可观测平台前建议在 Collector 侧做脱敏处理比如删除指定 attribute、对长文本字段做 hash 或截断。成本看板本身也要做权限控制避免所有员工都能看到全公司每个人的 AI 消耗明细。第四合理的采样与保留策略比保留全量更重要。AI 编程 Agent 的并发高峰期Trace 量可能非常大。建议在 Collector 层配置采样策略普通会话按 10% 采样保留对超长会话或高 token 会话全量保留这样既能评估成本趋势又不会让存储成本失控。历史数据保留周期建议按团队复盘节奏设置常见的是 30 天明细加 12 个月聚合。第五从“看板”走到“动作”。成本看板的最终价值是帮助团队回答三个问题谁在用、成本结构是否合理、怎样优化。建议在看板稳定之后接入按用户预算告警比如某用户单日成本超过阈值时通知管理员再进一步可以根据 Token 消耗和 Session 时长识别出“高消耗低产出”的会话模式推动团队优化 Prompt 或调整模型路由策略。9. 总结与后续学习方向这篇文章真正讲清楚的事情是Claude Code 这类 AI 编程 Agent 的成本本质上是一类新的可观测性问题。它不能靠“月底账单 人工估算”来管理而应该靠 OpenTelemetry 带来的结构化数据建立从 Trace 到 Span、从字段到用户、从量到成本的完整链路。Aimeterly 这个项目是否成熟、是否开源并不影响你理解这个方向重要的是它的出现说明AI 编程成本已经成为一个值得被工具化的工程领域。你可以按本文给出的最小链路先跑一遍本地启动 OpenTelemetry Collector配置 Claude Code 的 OTLP 导出用一份模拟数据验证 Python 脚本再逐步替换为真实数据。跑通之后值得继续深入的方向有几个一是研究 OTel 语义约定理解 attribute 命名如何影响聚合规则二是把看板接入 Prometheus 和 Grafana变成随时可查的监控面板三是把同一套思路扩展到 Codex、Cursor 等其它 AI 编程工具做一个统一的“多工具 AI 成本控制台”。从成本可见到成本可控中间差的不是预算额度而是数据管道。Claude Code 已经把最难的一步做完了剩下的事情值得每一个把 AI 编程当作团队正式工具的工程负责人认真投入。