Skip to content

功能规格说明:OpenTelemetry 集成 ​

规格文件:docs/specs/enterprise/opentelemetry.md创建日期:2026-05-09

用户场景与测试 (必填) ​

用户故事:指标与日志的 OTLP 远程导出(优先级:P1) ​

作为开发者,我希望将 Wave 的指标与事件日志发送到企业自己的 OTLP 收集器,以便统一度量会话成本、容量与使用模式,并把关键生命周期事件纳入既有的审计与检索体系。

为什么是这个优先级:这是企业接入的主要用例——把结构化的指标与事件交给外部后端,才能在不解析日志的前提下做成本与容量规划、定位使用阻力。

独立测试:通过 OTEL_EXPORTER_OTLP_ENDPOINT 将 Wave 指向一个 OTLP 收集器,运行会话,并验证指标与事件出现在收集器的查询界面中。

验收场景:

  1. 假设 OTEL_LOGS_EXPORTER=otlp 且有收集器端点,当会话开始和结束时,则收集器收到 session_start 和 session_end 的结构化日志事件。
  2. 假设 OTEL_METRICS_EXPORTER=otlp 且有收集器端点,当用户完成一轮消息交互时,则收集器收到按模型与类型标注的 token 用量、以及会话计数指标。
  3. 假设 同一进程内已经有会话结束过(长驻宿主可承载多个会话,如桌面端远程主机连的 daemon),当新会话开始、完成一轮并记录事件时,则收集器仍收到该会话的事件与指标——导出器是进程级的,单个会话结束不得停止采集。
  4. 假设 OTEL_METRICS_EXPORTER=none(或 OTEL_LOGS_EXPORTER=none),而同一台机器的用户级配置里却给该类信号配了导出器,当遥测初始化时,则该类信号不导出——none 是显式的「关掉这个信号」,环境变量优先于配置、不回退到配置里的导出器;另一类信号照常导出。

用户故事:运营指标(对齐 Claude Code 的指标面,优先级:P1) ​

作为把 Wave 接入企业度量平台的接入方,我希望有稳定命名的运营指标,覆盖成本(token 用量)、产出(代码行数、会话数、提交数与 PR/MR 数)、活动量(活跃时长)与摩擦(编辑类工具的权限决策),以便直接填进既有看板做容量规划与成本分摊,而不是靠解析事件日志自行聚合。

为什么是这个优先级:metrics 是 fleet 级问题的答案(哪个模型贵、用量是否回落、产出有多大),也是企业侧唯一能低成本长期保留的信号;事件日志适合审计、解析成本高。

独立测试:配置 OTLP 或 console 导出器,跑一轮包含多模型调用、一次被拒绝的工具调用、一次文件编辑与一次成功提交的会话,观察指标齐全且归属属性正确。

指标族(API 契约,命名 wave.*):

  • 用量类:wave.tokens(计数,按 model + type:input / output / cache_read / cache_creation)
  • 会话类:wave.sessions(计数)
  • 产出类:wave.lines.changed(计数,按 direction:added / removed,来源为编辑类工具的执行结果)、wave.commits(计数,来源为经由工具成功创建提交的动作)、wave.pull_requests(计数,PR 与 MR 合入同一计数,来源为经由 shell 命令或 MCP 工具成功发起创建 PR/MR 的动作)
  • 活动类:wave.active_time(计数,单位秒,按 type:user / cli,来源为已闭合的活跃区间长度)
  • 摩擦类:wave.code_edit_tool.decision(计数,按 decision:accept / reject,外加 source、tool_name、language,来源为编辑类工具的每次权限决策)
  • 采纳类:wave.completion.outcome(计数,按 outcome:displayed / accepted,外加 model、language,来源为编辑器宿主的补全建议生命周期,见「补全建议的采纳度量」)
  • 采纳类:wave.next_edit.outcome(计数,按同上两个 outcome 与同两组属性,来源为编辑器宿主的下一步编辑建议生命周期,见「下一步编辑建议的采纳度量」;与补全分列两条序列,不得合并成同一条比率)

其中 wave.commits / wave.pull_requests 对齐 Claude Code 的 commit.count / pull_request.count 口径:只认「经由工具、且成功」的创建动作,不看结果是否被合并、不依赖任何托管平台(私有化 GitLab 与 GitHub 同等成立),也不按托管平台或仓库拆分序列;标准归属属性按「统一的归属属性面」一节统一附带。

wave.active_time 对位 Claude Code 的 active_time.total,但口径按会话而不是按进程:同一进程承载多个会话时(daemon、桌面端远程主机),每个会话各自一段区间、各自归属自己的 session.id,因此多会话并发时按会话求和会大于进程墙钟——这是刻意的,它回答「这个会话干了多久」,不回答「进程活了多久」。type=cli 只计 agent 实际在跑的区间(模型调用、工具执行),弹窗等待用户决策与提问等待的时间被排除;并行与嵌套操作按墙钟只计一次。type=user 只计相邻两次用户输入间隔小于 5 秒的那段,长间隔视为挂机不计——该口径与 Claude Code 一致。

wave.code_edit_tool.decision 对位 Claude Code 的同名指标:只覆盖 Edit / Write(wave 没有 notebook 编辑工具),每次权限决策记一次(不是每次工具调用),被拒绝的决策同样计数。source 限定在 config / hook / user_permanent / user_temporary / user_abort / user_reject / unknown 七值内;language 由目标文件扩展名推导,推导不出时不带该属性,文件路径本身永不上报。cost.usage 不在这一组内:wave 的费用于服务端按计价规则计算,客户端自算会造出第二个口径。

指标面刻意不收请求次数、请求耗时与错误率:Claude Code 的指标集里没有这三项的对位(其指标覆盖会话数、代码行数、提交数、PR 数、成本、token 用量、编辑工具决策与活跃时长,同样不含请求侧计数),请求侧信息留在事件日志(error / tool_decision 等)里由审计侧消费——指标面只保留能跨客户横向对齐的家族,不为「多一个维度」把序列数撑开。补全这一族同理:只收采纳,不收补全请求的次数、延迟与错误率(见「补全建议的采纳度量」)。Claude Code 八族里唯一没有对位的是成本(cost.usage):费用在服务端按计价规则算,客户端再算一份只会得到两个对不上的口径。

指标名、单位与维度在两条通道上完全一致;值的语义(temporality)与导出节奏按通道分别定义——首方为 DELTA / 5 分钟,客户自建收集器为累计 / 60 秒,见「两条通道的聚合语义各按各的消费方定」。

验收场景:

  1. 假设 OTEL 指标已启用,当 agent 完成一次模型调用时,则 wave.tokens 按 input / output / cache_read / cache_creation 类型分别累加,归属属性为当次请求的模型。
  2. 假设 用户的一次模型调用失败(拿不到 usage),当 该轮结束时,则 不记录 token;失败本身走事件日志(error 事件),不在指标里重复计入。
  3. 假设 OTEL 指标已启用,当编辑类工具写入了文件变更时,则 wave.lines.changed 按 added / removed 分别累加编辑结果中的行数。
  4. 假设 用户没有配置任何自己的导出器(OTEL_METRICS_EXPORTER / OTEL_LOGS_EXPORTER 均未设),当会话进行时,则不产生任何走用户导出器的指标与事件——用户面默认仍是不导出(首方出口是独立的一条,见「默认上报到 Wave 服务端」)。
  5. 假设 一轮会话里发生自动压缩,当压缩完成后,则压缩后的 token 计数继续累计在同一会话的 wave.tokens 上(压缩不重置指标、不产生负值)。
  6. 假设 指标导出所需的耗时与错误信息(如失败原因字符串)存在,则 指标不携带提示词、文件内容或其它内容级数据,只带低基数归属属性。
  7. 假设 OTEL 指标已启用,当 agent 经由 Bash 工具成功创建了一个提交(含 git -C <dir> commit、前导环境变量赋值、--amend、以及 git add -A && git commit -m x 这类复合命令)时,则 wave.commits 递增一次;一次工具调用无论涉及多少个提交动作,最多只计一次。
  8. 假设 创建提交的命令执行失败(非 0 退出),则 wave.commits 不递增;由脚本内部或非提交类命令(如 git merge)间接产生的提交也不计——口径与 Claude Code 一致,只认「经由工具、且成功」的建提交动作。
  9. 假设 OTEL 指标已启用,当 agent 经由 shell 命令(如 gh pr create、glab mr create)或 MCP 的建 PR/MR 工具成功创建了一个 PR 或 MR 时,则 wave.pull_requests 递增一次;创建失败的调用不计;PR 与 MR 不作区分,同一个计数。
  10. 假设 这两个指标已启用,则 它们不带模型维度、也不因托管平台不同而分裂序列,并且不依赖任何 Git 托管平台的 API 或凭据——私有化 GitLab 与公有 GitHub 场景下行为一致;标准归属属性(会话与身份)按统一规则附带。
  11. 假设 OTEL 指标已启用,当 agent 发起一次模型调用并在其中逐个执行工具时,则 wave.active_time{type=cli} 只按墙钟记一段(并行与嵌套的工具执行、模型调用不叠加重计),每次上报的是这段已闭合区间本身的长度——既不是累计值,也不随进程存活时长增长。
  12. 假设 用户连续两次输入消息,当 两次之间的间隔小于 5 秒时,则 wave.active_time{type=user} 记下这段间隔;间隔达到 5 秒或更长时视为挂机,不记。
  13. 假设 一次工具调用在权限弹窗上等待用户批准(或等待问答工具的回答),当 该轮结束时,则 等待的这段时间不计入 wave.active_time{type=cli};同一段时间也不会以 type=user 重复计入。
  14. 假设 同一进程里两个会话同时在跑 agent(daemon 承载多会话),当 两个会话各自结束时,则 各自按自己的会话归属记一段 type=cli 的活跃时长,两条数据点带各自的 session.id,互不串味;此时按会话求和可以大于进程墙钟时间——该指标回答的是会话活跃时长,不是进程在线时长。
  15. 假设 OTEL 指标已启用,当 用户对一次 Edit 调用做了权限决策时,则 wave.code_edit_tool.decision 记一次,decision 为 accept 或 reject(被拒绝同样计数),并带上 source(config / hook / user_permanent / user_temporary / user_abort / user_reject / unknown)与 tool_name;能从目标文件路径推出语言时再带 language,推不出时不带该属性。
  16. 假设 一次会话里发生 Bash、Read 与 MCP 工具的权限决策,当 记录指标时,则 这些工具不产生 wave.code_edit_tool.decision——该指标只覆盖编辑类工具(Edit / Write)。

用户故事:补全建议的采纳度量(对齐 Continue 的口径,优先级:P2) ​

作为把 Wave 接入企业度量平台的接入方,我希望知道补全建议展示了多少条、被采纳了多少条,以便回答「行内补全到底有没有用、哪个模型真的被接受」,而不是只有对话侧的用量与产出。

为什么是这个优先级:补全与对话是两条刻意解耦的链路(补全不经 callAgent、无工具、无 system prompt),现有指标族全部落在对话路径与工具执行上,因此补全一条数据都没有——这是结构性缺口,不是漏插桩。它排在运营指标之后,是因为采集点在编辑器宿主而不是 CLI:CLI 只知道「我返回过一段文本」,采纳发生在平台内部(用户按 Tab),必须由宿主侧取信号回传,而取信号的方式两端各按自己的平台 API(见下文口径)。

独立测试:在任一端宿主(VS Code 或 JetBrains)里触发若干次补全并接受其中一部分,导出指标,验证 wave.completion.outcome 的 displayed 与 accepted 各自计数,且 accepted ÷ displayed 与手工计数一致;两端导出的属性集相同,可合并聚合。

指标族(API 契约):

  • wave.completion.outcome(计数,按 outcome:displayed / accepted,外加 model、language)
    • displayed:宿主真正渲染了幽灵文本时记一次(返回了非空文本、且未被取消、未因光标漂移丢弃)
    • accepted:用户接受该建议时记一次;同一条建议先 displayed 后 accepted,两个 outcome 各记一次
    • 采纳率由消费侧派生(accepted ÷ displayed),客户端不产出比值指标——比值不可加,混进可加计数会让聚合口径失效

口径与边界:

  • 只收采纳,不收请求侧:补全不报请求次数、请求耗时与错误率,与上文「指标面刻意不收请求次数、请求耗时与错误率」是同一条边界。
  • 与 wave.code_edit_tool.decision 无关、不可相加:两者是不同的事实——前者是建议结局(用户按了 Tab 接受这段文本),后者是权限决策(用户批准了这次 Edit / Write 动作);分母也不同(展示过的建议数 vs 权限请求次数)。看板卡片各自独立,禁止合并成「总采纳率」;取值词形也刻意不同(accept / reject vs displayed / accepted),避免交叉误读。
  • 分母 = 展示数,不细分未采纳的原因(口径来源:Continue 的 core/autocomplete/util/AutocompleteLoggingService.ts——每个建议记一条带 accepted: boolean 与展示时刻的事件,采纳率即 accepted / displayed):不区分「用户继续输入而失效」与「返回但未渲染」——后者只有宿主能判定,猜错会把噪声写进口径,因此只记「展示了」「被接受了」这两个可确证的事实。它的维度侧不照搬:Continue 用文件扩展名与 modelName + modelProvider,我们沿用既有指标族的 language / model 取值域,以便与本仓其他指标横向聚合。
  • 两端宿主都上报,取信号的方式各按平台:VS Code 用 InlineCompletionItem.command(接受时执行的钩子);JetBrains 用平台的 InlineCompletionProvider.insertHandler——afterInsertion() 在建议被插入文档时回调,IC-2024.2 起可用(Continue 的 JetBrains 插件走的正是这条路径,不是 diff 反推)。displayed 两端都在「返回了一条非空建议」时记。若将来出现确实拿不到采纳信号的宿主,该端整条不上报(宁缺勿错)——只报 displayed 会让采纳率恒为 0,比没有数据更糟。
  • 不携带内容级数据:不带文件路径、代码内容或光标位置。
  • language 复用 wave.code_edit_tool.decision 的同一套推导(telemetry/language.ts 的 languageFromPath():由文件扩展名得到小写语言名,如 typescript / javascript / shell,认不出时不带该属性),不直接取宿主的语言标识——VS Code 的 document.languageId 会给 typescriptreact 这类与既有族不同名的值;宿主回传时只带文件路径与建议标识,语言由 CLI 侧统一推导,路径本身永不上报。补全请求本来就带文件路径,因此这一层不新增协议负担。model 取该次请求实际使用的模型(未配置补全模型时回退到 fastModel,指标反映回退后的真实值)。

验收场景:

  1. 假设 补全已启用且导出器就绪,当宿主渲染了一条幽灵文本时,则 wave.completion.outcome{outcome=displayed} 递增一次,并带 model 与 language。
  2. 假设 用户接受了这条建议,当接受动作发生时,则同一建议再记一次 {outcome=accepted};从未被接受则不产生 accepted。
  3. 假设 用户继续输入导致建议失效(未接受),当该建议被顶掉时,则只有 displayed 一次、没有 accepted——不引入第三种 outcome,指标里也不记录失效原因。
  4. 假设 宿主是 JetBrains,当用户接受了一条建议时,则平台经 insertHandler 回调,该端同样记一次 {outcome=accepted},与 VS Code 的属性集完全一致(两端数据可合起来按同一口径聚合)。
  5. 假设 一次 Edit 调用的权限决策发生,当记录指标时,则只有 wave.code_edit_tool.decision 递增,wave.completion.outcome 不变;两个比率在消费侧各自派生、互不相加。
  6. 假设 这两条序列被导出,则数据点不带文件路径、代码内容或光标位置,只带低基数的 model / language 与标准归属属性。

用户故事:下一步编辑建议的采纳度量(优先级:P3) ​

作为把 Wave 接入企业度量平台的接入方,我希望下一步编辑建议的展示与接受也各自计数,以便回答「这个会改写用户已有代码的能力是否值得保留」,而不是把它塞进补全那条比率里把两个口径搅在一起。

为什么是这个优先级:它是补全采纳度量的同族扩展,口径与取信号方式和补全完全一致,只是序列单列;没有它不影响用户使用,但会让「该不该继续投入」无从判断。

独立测试:在一端宿主里触发若干次下一步建议并接受其中一部分,导出指标,验证 wave.next_edit.outcome 的 displayed 与 accepted 各自计数,且两者的属性集与补全一致(可与其他族横向聚合),同时 wave.completion.outcome 的计数完全不受这些动作影响。

口径与边界:

  • 序列单列,不并入补全:补全的采纳率问的是「光标处接着写什么有人要吗」,下一步建议问的是「改写已有代码有人要吗」;两者分母不同,合成一条比率两个问题都答不了。
  • 取信号的方式与补全同一套:displayed 由宿主在真正渲染建议时记(VS Code 走 InlineCompletionItem 的接受命令前一步,JetBrains 走平台的行内补全插入回调);拿不到采纳信号的宿主整条不上报,只报 displayed 会让比率恒为 0。
  • 不携带内容级数据:不带文件路径、代码内容、建议所在区间或光标位置;model 与 language 的取值域与补全完全相同。

验收场景:

  1. 假设 下一步建议的开关已开启且导出器就绪,当 宿主渲染了一条建议时,则 wave.next_edit.outcome{outcome=displayed} 递增一次并带 model 与 language;用户接受它时再记一次 {outcome=accepted}。
  2. 假设 同一次会话里补全与下一步建议都有展示与接受,当 导出这两条序列时,则 两者的计数互不影响,消费侧派生的两个比率各自独立。

用户故事:统一的归属属性面(对齐 Claude Code 的标准属性,优先级:P1) ​

作为把 Wave 接入企业度量平台的接入方,我希望每条指标数据点与每条事件都自动带上同一组低基数归属属性(会话、身份、资源自定义属性),以便在收集器侧按人、按会话、按团队切片,把用量摊到团队与成本中心,而不是只能看全局总量、或只有少数事件能归属到会话。

为什么是这个优先级:属性决定了指标「能不能用」——没有会话标识的指标只能看总量,没有身份属性就无法做成本分摊。而长驻宿主(daemon、桌面端远程主机)在同一进程里承载多个会话,缺了会话标识的事件在收集器里无法归属到具体会话(现状是只有 session_start 带会话标识)。这也是 Claude Code 的做法:标准属性对所有指标与事件生效。

独立测试:在同一个进程里跑两个会话(例如 daemon 承载两个会话),导出到收集器,验证每条记录都带 session.id 且各自归属正确的会话;再设 OTEL_RESOURCE_ATTRIBUTES="team=infra",验证该键在指标侧可用。

属性面(API 契约):

默认生效:

  • session.id:每条指标数据点与每条事件都带,同一会话内取值一致、同进程内不同会话互不串味;指标侧由开关 OTEL_METRICS_INCLUDE_SESSION_ID 控制(默认开),事件面恒带。
  • 身份:user.id 与 user.email(有则带)——与事件日志共用同一套归属属性构造:已认证时用账号 id 与邮箱,未认证时用本机持久匿名 id(不含 PII)。指标与事件口径一致,恒带。
  • terminal.type:从终端环境探测得到的终端类型,探测不到则不带。
  • 遥测资源:接入 OTEL_RESOURCE_ATTRIBUTES 机制(接入方可用它放自己的团队 / 部门标识),并把这些键投影到指标数据点属性(默认开,开关 OTEL_METRICS_INCLUDE_RESOURCE_ATTRIBUTES);资源层始终保留。
  • service.version:遥测资源里的版本号取构建期注入的真实版本,不再因取值 unknown 让服务端按版本分裂序列。

按需开启(默认关,独立开关控基数):

  • 仓库维度:vcs.repository.url.full / vcs.owner.name / vcs.repository.name / vcs.provider.name(开关 OTEL_METRICS_INCLUDE_REPOSITORY,默认关,对齐 Claude Code 默认)——从会话工作目录的 origin remote 派生,不落本地绝对路径;不是 git 仓库或没有 origin 时整组不带。

这些开关只有环境变量一种形态,写进 ~/.wave/settings.json 的 env 块(与其它 OTEL_* 同一落点,见「遥测配置的载体与 Claude Code 同形」)。

与 Claude Code 的刻意差异:不新增 app.version 属性——版本已在遥测资源里,加第二个版本属性只是重复;身份属性固定为 user.id + user.email(我们账号体系没有 account_uuid / organization.id,不造字段)。

验收场景:

  1. 假设 指标已启用,当任一指标记录一次时,则 该数据点带 session.id 与身份属性(user.id,已认证时含 user.email)。
  2. 假设 事件已启用,当任一事件(含 compaction / tool_decision / user_prompt / error)记录时,则 该事件带 session.id 与身份属性,而不只是 session_start 带。
  3. 假设 同一进程里同时存在两个会话(daemon 承载多会话),当 两个会话各自记录指标与事件时,则 每条记录归属自己的 session.id,不出现串味。
  4. 假设 OTEL_METRICS_INCLUDE_SESSION_ID=0,当记录指标时,则 指标不带 session.id;事件面不受该开关影响。
  5. 假设 设了 OTEL_RESOURCE_ATTRIBUTES="department=infra,team.id=42",当遥测导出时,则 这些键出现在遥测资源里,且指标数据点属性里也带同样的键(数据点投影与收集器是否把资源属性落成序列标签无关);OTEL_METRICS_INCLUDE_RESOURCE_ATTRIBUTES=0 时只保留在资源层。
  6. 假设 读取遥测资源里的 service.version,当从任意安装形态(非 npm 脚本启动、打包后的宿主)运行时,则 是真实版本号,不出现 unknown。
  7. 假设 OTEL_METRICS_INCLUDE_REPOSITORY=1 且会话工作目录是带 origin 的 git 仓库,当记录指标与事件时,则 带上 vcs.repository.* 四个键(url.full 为远端 URL,owner.name / repository.name 从 URL 解析,provider.name 为 github / gitlab / unknown);仓库的本地路径不出现在遥测里。
  8. 假设 OTEL_METRICS_INCLUDE_REPOSITORY 未开启(默认)或工作目录不是 git 仓库,则 不产生任何 vcs.* 键——默认配置下遥测不泄露内部仓库坐标。
  9. 假设 遥测启用但未认证,当记录指标时,则 身份属性为匿名 id(每机一个、跨会话稳定),不出现空值或本机路径。

用户故事:宿主入口标识(优先级:P2) ​

作为接入方,我希望遥测里能区分一条记录来自哪个宿主入口(CLI / 桌面端 / VS Code / JetBrains / 远控通道),以便判断各端的使用分布与问题面,而不是所有端混成一个量。

为什么是这个优先级:这是属性面里唯一需要跨宿主改动的一项——SDK 目前没有「宿主是谁」的概念,取值必须由宿主自己声明(宿主经 stdio 协议把客户端标识交给 SDK);不配也能用,因此排在其他属性之后。

属性面(API 契约):

  • app.entrypoint:取值来自创建(或 daemon 重启后重新托管)这条会话的那个客户端所声明的入口标识,取值域是一张固定的表:cli(交互式 CLI 与 -p)、desktop、vscode、jetbrains、remote(远控通道——手机 / Web 页经中继建出来的会话)。会话跑在哪个进程里不决定取值:daemon 与交互式 CLI 都能承载远控建出来的会话,那类会话记 remote,与同一进程里本机建的会话区分开。
  • 事件面恒带;指标侧由开关 OTEL_METRICS_INCLUDE_ENTRYPOINT 控制(默认关,对齐 Claude Code);配置文件的形态同样是 env 块里这个键,不另有结构化键。
  • 未声明就不带(不猜、不填占位值);取值域之外的值当作未声明处理。

独立测试:分别从 CLI、桌面端(--stdio)、VS Code 各跑一次会话,验证事件里 app.entrypoint 分别是各端的取值;未声明标识的运行形态不带该属性。

验收场景:

  1. 假设 宿主在创建 agent 时声明了入口标识,当记录遥测时,则 事件带 app.entrypoint,取值为该宿主声明的标识(cli / desktop / vscode / jetbrains / remote);指标在开关开启时同样带上。
  2. 假设 宿主未声明标识(直接用 SDK、或旧版宿主),当记录遥测时,则 不带 app.entrypoint(不猜、不填默认值),其余属性照常。
  3. 假设 OTEL_METRICS_INCLUDE_ENTRYPOINT 未开启(默认关,对齐 Claude Code),当记录指标时,则 指标不带该属性;事件面不受该开关影响。
  4. 假设 声明的标识不在取值域内(老宿主、第三方客户端、手写报文),当记录遥测时,则 当作未声明处理——不带该属性、也不把原值落成新序列,其余属性照常。
  5. 假设 同一进程里既有本机建的会话、也有远控通道建的会话(daemon 同时承载两者),当两者各自记录遥测时,则各带自己的 app.entrypoint(cli / desktop 与 remote),不互相串味。

用户故事:默认上报到 Wave 服务端(开箱可用的研发效能度量,优先级:P1) ​

作为企业接入方,我希望 Wave 客户端默认把研发效能指标与生命周期事件上报到 Wave 服务端,以便在管控台直接看到团队的用量、成本与交付产出(提交数、PR/MR 数、变更行数),而不需要自建 OTLP 收集器、也不需要逐台配置。

为什么是这个优先级:交付产出类指标只在客户端侧产生——网关只看得见模型调用,无从推断本地是否提交过、改了多少行;要让它出现在管控台上,客户端必须默认把这条数据送出去。不解决默认出口,管控台就只剩网关口径的用量与成本,研发效能那一层永远缺席。

独立测试:在一台未做任何遥测配置、以登录态运行的机器上跑一轮含文件编辑与一次成功提交的会话,验证 Wave 服务端收到该会话的 wave.* 指标与 session_start / session_end 事件,且管控台能按人、按仓库、按模型看到它们。

验收场景:

  1. 假设 首方出口处于其默认状态且用户没有配置任何遥测项,当 会话进行时,则 指标与事件上报到 Wave 服务端,全程无需任何用户配置(默认状态为开——服务端入口已在全部环境就绪,客户端据此默认开启)。
  2. 假设 用户另外配置了自己的 OTLP 收集器,当 会话进行时,则 自己的收集器照常收到这批数据——首方出口与用户自配的导出器是两条独立管道,互不顶替:用户配置不关闭首方出口,首方出口也不占用用户配置的 endpoint。
  3. 假设 用户要「一条命令关掉全部遥测出站」(合规 / 审计场景),当 设置 DISABLE_TELEMETRY=1 时,则 首方与用户自配的导出器一起停——该开关与 Claude Code 同名同义,是总闸。没有按管道分开的窄门:CC 也没有「只关第一方」的开关,我们同样不提供;只想关掉客户自己那条时,把 OTEL_*_EXPORTER 去掉或设成 none。
  4. 假设 未登录、或 Wave 服务端地址不可用,当 会话进行时,则 不产生任何首方流量、不报错、不阻塞会话。
  5. 假设 首方入口不可达或返回错误,当 导出发生时,则 优雅失败并记录限流警告,会话照常继续(与用户自配收集器不可达时同构)。
  6. 假设 默认配置且已上报,则 归属维度为 user.email / session.id / model——人、模型默认带;仓库维度默认关,只有设置 OTEL_METRICS_INCLUDE_REPOSITORY 后才带 vcs.repository.*。
  7. 假设 产品没有团队 / 部门概念,则 首方度量面不提供团队或部门切片;department_name / team_id 仍可由部署方通过 OTEL_RESOURCE_ATTRIBUTES 自行注入,首方不主动采集。
  8. 假设 默认配置,当 采集事件时,则 提示词文本(需 OTEL_LOG_USER_PROMPTS)与工具内容(OTEL_LOG_TOOL_CONTENT)默认关闭——默认上报的是指标与生命周期事件,不含提示词、代码内容与文件路径。
  9. 假设 托管配置在 env 块里下发了遥测键(改 endpoint / 关掉首方出口 / 加出站头),当 会话启动时,则 该配置生效且优先于本地设置(与托管层既有的按字段合并语义一致)。

用户故事:两条通道的聚合语义各按各的消费方定(首方 DELTA / 5 分钟,优先级:P1) ​

作为管控台的使用者与运维,我希望首方通道上报区间增量(DELTA)、导出间隔 5 分钟,而客户自建收集器继续收累计值(CUMULATIVE)、导出间隔 60 秒,因为这两类消费方读 counter 的方式根本不同,任何一方迁就另一方都会坏掉。

为什么是这个优先级:首方入口把点长期存下来、查询时按时间窗聚合。累计值意味着每个点都带着该进程的整段历史——进程越跑越重(每次导出重发全部序列),而回答「昨天一共花了多少 token」只能把几十万行原始点取回来、在应用进程里逐点相减,这就是研发效能看板冷查 43–59s 的根因。客户侧的 Prometheus 型时序库正好相反:rate() / increase() 依赖累计值与其回落来算速率,喂 delta 会砸掉接入方在建的每一块 counter 看板。同一批指标必须用两种方言出门,这不是可调参数,是契约。

独立测试:在一台登录态机器上跑一轮含两次模型调用的会话(两次的 input token 数不同,例如 100 与 30),等一个窗口后检查首方入口:wave.tokens 的 type=input 序列在第二个窗口的点值是第二次的 30(不是两次之和 130),载荷 temporality 为 DELTA;同一台机器同时配了客户 OTLP 收集器时,收集器收到的同一序列第二个窗口是 130(累计)。

验收场景:

  1. 假设 首方出口处于默认状态,当指标导出时,则 载体是 DELTA,且该设置由客户端显式声明——不依赖也不受制于 OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE:接入方为它自己的收集器把该环境变量设成 cumulative,也翻不动首方通道(显式设置优先于环境变量)。
  2. 假设 用户自配了 OTLP 收集器,当指标导出时,则 该通道仍按 OTLP 默认(CUMULATIVE)上报、且不设置任何显式 temporality——接入方仍可用环境变量自行切换,rate() / increase() 语义保持不变。
  3. 假设 同一进程里两条通道同时存在,当一个导出周期结束时,则 同一个 MeterProvider 的两份载荷各按自己的 temporality 出点(同一份聚合状态,出两份不同语义的载点),谁都不影响谁。
  4. 假设 首方通道导出,则 导出间隔为 5 分钟(不是 OTLP 默认的 60 秒);客户通道仍按 60 秒(或 OTEL_METRIC_EXPORT_INTERVAL)——两个间隔各是独立常量,调一个不动另一个。
  5. 假设 某个窗口内该序列没有任何新增(无请求、无编辑、无提交),当该窗口结束时,则 首方不产生这条序列的点(DELTA 只报有增量的序列,整窗无增量时连一次导出都不发生)。因此服务端不得把「收到点」当心跳:最后上报时间 表达的是「最后一次有活动的时间」,活跃会话 必须按窗口内有增量判定,而不是按有没有点。
  6. 假设 会话在窗口中途结束、退出时触发 flush,则 首方会收到一个较短窗口的增量点;服务端按点相加即可,不得假设点是按 5 分钟整齐切分的桶。
  7. 假设 首方是这台机器上唯一的指标管道,则 resource 带 aggregation.temporality: "delta" 标记(供入口与排查一眼看出方言);假设 同时还有客户指标管道,则 不带该标记——resource 由两条通道共用,标了就会与随行的累计载荷自相矛盾,宁可没有标记也不给错标记。
  8. 假设 这批改动需要回滚,则 服务端与客户端必须一起回滚:只回滚一端会让「累计值被当增量相加」(或反之),已落库窗口的数字直接错。

用户故事:通过事件日志的会话诊断(优先级:P2) ​

作为开发者,我希望关键会话生命周期事件(开始、结束、压缩、工具决策、错误)有结构化事件日志,以便无需解析原始 JSONL 文件即可重建会话期间发生的事情。

为什么是这个优先级:这提供了可搜索的结构化审计跟踪,补充原始消息历史。事件通过配置的 logs 导出器导出(OTLP 用于远程,console 用于本地观察)。

独立测试:使用 OTEL_LOGS_EXPORTER=otlp 运行 Wave,完成包含多轮包括压缩和被拒绝工具调用的会话,并验证所有生命周期事件出现在收集器中。或将导出器设为 console,直接观察事件以可读形式打印。

验收场景:

  1. 假设 OTEL 日志已启用,当会话开始时,则记录 session_start 事件,包含 model 和 workdir,并按标准属性面带 session.id。
  2. 假设 OTEL 日志已启用,当 agent 自动压缩对话时,则记录 compaction 事件,包含压缩前后的 token 计数。
  3. 假设 OTEL 日志已启用,当工具权限被拒绝时,则记录 tool_decision 事件,包含工具名称和决策。
  4. 假设 OTEL_LOG_USER_PROMPTS=1,当用户发送消息时,则 user_prompt 事件包含实际提示文本。假设 OTEL_LOG_USER_PROMPTS 未设置,则排除提示文本。

用户故事:console 导出器(零依赖本地观察,优先级:P2) ​

作为开发者,我希望把事件与指标直接以可读形式打印到终端来观察遥测内容,以便不配置任何收集器就能当场调试(对齐 Claude Code 的 console 导出器)。

为什么是这个优先级:交互 TUI 默认接管 console 输出(Ink patchConsole),打印内容呈现在当前帧上方、不打碎界面;但 stdout 在结构化输出宿主里是协议通道(--stdio 的 JSON-RPC、--print 的结果流),console 打印会直接破坏协议——所以该选项必须与「非交互宿主自动剥除」配套,与 Claude Code 在 stream-json 模式下的处理同构。

独立测试:交互模式下把导出器设为 console,运行一轮会话,观察终端出现结构化输出且交互不受阻;再以 --print 或 --stdio 运行同样配置,确认输出通道没有 console 打印的内容、协议报文完好。

验收场景:

  1. 假设 某类信号的导出器为 console 且处于交互 TUI,当 agent 处理消息时,则 结构化输出呈现在界面上方,交互正常继续。
  2. 假设 同一配置在 --stdio / --print 模式下运行,当 会话进行时,则 stdout 上没有 console 打印的内容,协议通道保持纯净。

用户故事:遥测配置的载体与 Claude Code 同形(优先级:P1) ​

作为从 Claude Code 迁入的接入方,我希望遥测的配置方式和它完全一样——设置文件里只多一个 otelHeadersHelper 专键,其余 OTEL_* 全部写在 env 块里,以便把已有的托管配置、启动脚本和运维文档原样搬过来,不必为两台工具维护两套配置词汇。

为什么是这个优先级:配置载体是迁移的第一道门槛。键名逐条对齐了、落点却不同(结构化块 vs env),客户就得为同一批设置写两份;托管层尤其明显——管理员手里那套 OTEL_* 在 CC 是能直接下发的,在这里却要翻译成 monitoring.telemetry.* 才能用。

独立测试:在一台只配了 env 块(OTEL_METRICS_EXPORTER / OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_LOG_USER_PROMPTS)的机器上跑一轮会话,验证收集器收到指标与带提示词的事件;再用同一批键走托管下发,验证效果一致。

验收场景:

  1. 假设 用户级 ~/.wave/settings.json 的 env 块里写了 OTEL_METRICS_EXPORTER=otlp 与 OTEL_EXPORTER_OTLP_ENDPOINT,当 会话启动时,则 指标按该配置导出到收集器——env 块对遥测与对其它配置项同等生效(读合并后的本轮环境快照,不是进程启动时的 process.env)。
  2. 假设 同一个键既出现在 env 块、又存在于操作系统环境,当 解析遥测配置时,则 以 env 块为准——与既有「设置文件覆盖 OS 环境」的优先级一致。
  3. 假设 服务端托管配置下发了 env 块里的 OTEL_*,当 会话启动时,则 该配置生效且优先于本地同名键(按字段合并,托管层胜)。
  4. 假设 配置里出现 otelHeadersHelper(settings.json 顶层的单个键,与 CC 同名同位置),当 导出时,则 按「出站凭据由命令动态提供」一节执行;它不进 env 块——会执行命令的配置不与普通环境变量混排。
  5. 假设 otelHeadersHelper 出现在项目级 .wave/settings.json / settings.local.json,当 配置加载时,则 该键被忽略并记录警告(随仓库分发的配置不得指定本机要执行的命令),同一份文件里的其它遥测 env 键照常生效。
  6. 假设 项目级配置的 env 块里写了 OTEL_METRICS_EXPORTER 等普通遥测键,当 会话启动时,则 照常生效——与 Claude Code 的项目层白名单口径一致:OTEL_* 属于放行项,otelHeadersHelper 是唯一例外。
  7. 假设 配置里仍留着已下架的 monitoring.telemetry 块,当 配置加载时,则 该块不被读取(键名已不在配置模型里),会话照常启动;遥测只认 env 块里的 OTEL_* 与顶层 otelHeadersHelper。
  8. 假设 管理员要按整队关掉全部遥测出站,当 托管配置把 DISABLE_TELEMETRY=1 下发到 env 块时,则 首方与客户自配的导出器一起停——与 CC 的托管配置下发同名键完全同构。
  9. 假设 用户只是往 env 块里写了导出器,当 遥测初始化时,则 遥测即为开启(配置即开关),不需要额外的「启用遥测」变量;关掉某条客户信号用 OTEL_METRICS_EXPORTER=none / OTEL_LOGS_EXPORTER=none(与 CC 的 none 同义)。不引入 CLAUDE_CODE_ENABLE_TELEMETRY 那种 opt-in 门:我们的默认是「配了才发」,没有需要 opt-in 的默认出口。

用户故事:出站凭据由命令动态提供(优先级:P2) ​

作为把遥测接到企业内部收集器的接入方,我希望 OTLP 出站头由一条命令在发送前提供,而不是写死成配置里的字符串,以便凭据轮换或吊销后不需要重新下发配置、也不需要重启长驻宿主(桌面端 / VS Code)里的会话。

为什么是这个优先级:不配动态头时一切照旧,因此不阻塞;但只要上报目标要求「凭据按设备签发、有生命周期、可吊销」,静态头就是硬伤——要么把 token 烧进分发包,要么每次轮换让全员改配置重启。

独立测试:在 ~/.wave/settings.json 顶层把 otelHeadersHelper 指向一个打印固定 JSON 头的脚本,把导出目标指向一个记录请求头的本地收集器,运行一次会话;再把脚本改成打印另一组头、等过一个刷新间隔,观察收集器收到的是新头。

验收场景:

  1. 假设 用户级配置里给了 otelHeadersHelper 且同时存在静态 headers(OTEL_EXPORTER_OTLP_HEADERS),当 导出器发送一批数据时,则 该批次带两组头,同名键取命令给出的值。
  2. 假设 helper 上一次成功返回过头,当 刷新间隔到达后 helper 失败(非 0 退出 / 超时 / stdout 不是合法 JSON 对象)时,则 导出沿用上一次成功取到的头继续发送,agent 会话不受影响,并记录一条限流警告(只在失败种类变化时重复)。
  3. 假设 helper 从未成功过,当 导出时,则 只带静态头发送,批次不被丢弃、会话不中断。
  4. 假设 同一刷新间隔内有多次导出(多个信号、多个批次),则 命令在该间隔内只被执行一次(并发调用共享同一次运行,导出器之间共享同一份缓存)。
  5. 假设 间隔已过且 helper 恢复正常,当 下一次导出时,则 使用新取到的头(无需重启进程)。
  6. 假设 宿主在进程内注入了取头函数(AgentOptions.telemetry.headersProvider),当 导出时,则 使用注入函数的返回值,配置里的 helper 命令不执行。
  7. 假设 otelHeadersHelper 只出现在项目 .wave/settings.json / settings.local.json(随仓库分发)里,当 配置加载时,则 该键被忽略(同一份文件里的遥测 env 键照常生效)并记录警告。
  8. 假设 helper 失败,则 失败信息只包含失败种类与退出码;命令的 stdout 不进入日志与审计。
  9. 假设 otelHeadersHelper 写在 settings.json 顶层、或由服务端托管配置下发,当 会话启动时,则 该键到达 SDK 并参与导出配置解析;托管层与本地层按字段合并,同名键以托管层为准。

边界情况 ​

  • 如果导出端点(OTLP 收集器 / 转发层入口)不可达会怎样? 遥测导出应优雅失败并记录警告;agent 会话必须正常继续而不阻塞。
  • 如果出站头由命令提供、而该命令失败会怎样? 遥测链路保持可用(沿用上次成功的头,否则只发静态头),失败以限流警告呈现;agent 会话必须不受影响,命令 stdout(即凭据)不得进入任何日志面。
  • 如果出站头命令来自仓库内的配置文件会怎样? 该键被忽略(随仓库分发的配置不得指定本机要执行的命令),其它遥测设置仍生效。
  • 如果收集器不把遥测资源属性落成序列标签会怎样? 归属属性仍可用:资源属性同时投影到指标数据点属性(默认开),按团队 / 部门过滤在指标侧成立;资源层始终保留原键。
  • 如果接入方在 OTEL_RESOURCE_ATTRIBUTES 里放了高基数取值(如会话 id)会怎样? 序列数量由该取值决定,属于接入方配置选择;我们只为自己引入的维度提供基数开关(会话、仓库),不校验对方自定义的键值。
  • 如果接入方自己的 OTEL_RESOURCE_ATTRIBUTES 里也写了 aggregation.temporality 会怎样? 我方声明的值覆盖它(resource 合并顺序为「SDK 默认 → 环境变量 → 我方」,后者胜);带不带这个标记仍按「首方是唯一指标管道」判定。
  • 如果只回滚了这批 temporality 改动的一端(客户端或服务端)会怎样? 聚合结果直接错,且不会报错:服务端按增量相加却收到累计值时,窗口数字会随进程运行时长膨胀;反之按累计做差却收到增量时,结果接近 0 或出现负值。所以这条契约的回滚必须两端同批进行,落库侧还要考虑已写入窗口的清理口径。
  • 如果仓库维度开关打开、而工作目录不是 git 仓库会怎样? 整组 vcs.* 键都不出现(不填占位值);会话照常运行。
  • 如果用户没有配置任何导出器会怎样? 用户面不设置默认导出器(首方出口不受此条约束,见「默认上报到 Wave 服务端」)。用户要接到自己的收集器必须显式配置至少一个导出器。这避免在交互模式中意外的 stdout 污染。
  • 如果某类信号的导出器被显式设为 none 会怎样? 该类信号不导出,且不回退到配置文件里的导出器——none 是「关掉这个信号」,与下一条认不出的遗留取值不同;另一类信号不受影响。
  • 如果服务端入口在某个环境尚未就绪、而客户端已默认开启会怎样? 首方出口的默认值由一个客户端内建常量(FIRST_PARTY_TELEMETRY_DEFAULT_ENABLED)承载,与服务端入口的部署解耦:入口在全部环境就绪后该常量为开,客户端开箱即上报到 {服务端地址}/otel;对尚无入口的部署,退出口是总闸 DISABLE_TELEMETRY=1(首方与自建导出器一起停,可经托管配置下发到 env 块按整队生效);导出失败也只走优雅失败、不阻塞会话(见上一条)。
  • 如果 env 块里写了认不出的遥测取值(如 OTEL_METRICS_EXPORTER=jsonl)会怎样? 忽略并告警,其余遥测设置照常生效——与 OS 环境变量里出现同类错误取值的处理一致。
  • 如果用户自配的收集器与首方出口同时存在会怎样? 两条管道各自发送、互不影响,同一批指标与事件在两边各有一份;任一条失败不影响另一条,也不影响会话。
  • 如果 --stdio / --print 宿主下开了首方出口会怎样? 首方出口是 OTLP 出站,不写 stdout;console 导出器在结构化输出宿主被剥除的既有行为不变。
  • 如果 console 导出器跑在结构化输出宿主(--stdio / --print)里会怎样? console 在遥测初始化处被自动剥除(对齐 Claude Code 的 stream-json 处理),stdout 协议通道保持纯净;console 只在交互 TUI 下生效。
  • 如果配置里还留着早期版本的导出器值(jsonl、tracesExporter 整键)会怎样? 认不出或已下架的取值被忽略并记录告警,其余遥测设置照常生效——不因为遗留配置使整个遥测初始化失败。
  • 如果宿主声明的入口标识认不出(取值域外的值)会怎样? 当作未声明处理:不带 app.entrypoint,也不把原值落成新序列——一次拼写错误不该在收集器里长出一条永远没人看的序列;其余属性照常。
  • 如果遥测初始化失败会怎样? agent 必须在没有遥测的情况下正常启动;记录警告但不崩溃。