---
url: /wave-agent/specs/enterprise/opentelemetry.md
description: >-
  OpenTelemetry 指标与日志插桩：默认上报到 Wave 服务端的首方出口、console 与 OTLP
  导出器、可轮换的出站头与统一的标准归属属性面
---

# 功能规格说明：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 必须在没有遥测的情况下正常启动；记录警告但不崩溃。
