# 第 16 章：可观测性——一次任务的全程可追溯

> 分布式数据库能对一条 SQL 做 EXPLAIN，把它拆成算子：走了哪些步、每步读多少行、花多久、有没有倾斜。Claude Code 对它自己做的是同一件事——你交给它一个任务，它跨多个 turn 完成，每个 turn 打了哪些 API、调了哪些工具、花了多少 token 和钱、在哪等你批权限、有没有报错重试，全被记下来、能串成一条链回放。这一章讲它怎么做到，以及为什么这么设计。

## 16.1 三类问题，四种记录

先看要解决的问题。你让 Claude Code 修个 bug，它不是“调一次模型”就完事，而是跑一串 turn：读文件、定位、改、等你批、执行、跑测试、总结。事后你可能想查三类完全不同的东西：

- 这个月团队一共花了多少钱、多少 token？你要的是能累加的数字，不关心是哪一次 prompt 产生的。
- 上周那次 `git push` 是谁批的？你要的是能按条件筛的一条条离散记录。
- 这次修 bug 的七八步里，哪一步最慢、卡在哪？你要的是带父子关系、带耗时的一棵调用树，也就是 EXPLAIN 里那棵算子树。

三类问题，数据结构完全不同——能聚合的计数器、可查询的事件流、有嵌套的追踪树。混在一个大日志里，哪类都查不好。所以 Claude Code 把可观测性拆成对应的几层，每层只干一件事。

具体是四层：前三层是标准的 OpenTelemetry 三件套（指标 / 事件 / 追踪），第四层是它每次运行都往硬盘写的会话记录。

- 指标（Metrics）回答“总账”类问题——一堆只增不减的计数器：会话数、token、花费、改动行数。为了能被时序库高效聚合，它刻意只带很粗的维度（模型名、token 类型），绝不带每次 prompt 的 ID：否则维度爆炸，库就被拖垮。这条“聚合层拒绝高基数 ID”的红线后面会反复出现。
- 事件（Events）回答“某件事”类问题。每打一次 API、每调一个工具、每做一次权限决策，都留一条带时间戳的离散记录，你能按 prompt、按工具、按结果去筛。
- 追踪（Trace）回答“整条链”类问题。它把一次任务建成一棵树：一次交互是根，底下挂着每个 turn 的模型调用、每次工具执行、还有“卡在等你批权限”那段时间，每个节点都带耗时。这是最贴近 EXPLAIN 的一层，但它默认关着、还在 beta，原因后面讲。
- 会话记录（Transcript）是那份“默认就在、人人都有”的追踪。前三层要你主动开遥测、配后端才看得到；而每次运行，CC 都会往 `~/.claude/projects/` 写一个 JSONL，把每条消息、每次 token 用量、消息间的父子关系都记下来——`/resume` 靠它，社区分析工具也靠它。对绝大多数人，这才是实际用得上的那份 trace。

### 怎么把一次 prompt 的数据从四层里拼回一条链

四层各记各的，但你查问题时想要的是“这一次 prompt 的全部账”。把它们对齐到同一次 prompt，靠一个关联锚点：一个 UUID。

用户输入进来时，CC 先生成这个 UUID（叫 `prompt.id`），存进全局状态，然后在同一处代码里做两件事——开一棵追踪树的根，发一条 `user_prompt` 事件。也就是说，追踪和事件从一开始就被挂到了同一次 prompt 上：

```typescript
// utils/processUserInput/processTextPrompt.ts:31
const promptId = randomUUID()
setPromptId(promptId)
startInteractionSpan(userPromptText)          // 开追踪树的根
void logOTelEvent('user_prompt', { /* … */ 'prompt.id': promptId })
```

但四层用这个 id 的方式并不一样，说清楚这点很重要，否则容易以为它是平铺在四层的同一个字段：

- 事件层：每条事件都自动带 `prompt.id`（`events.ts:49`）。这是它真正的关联键，同一次 prompt 的所有事件靠它归拢。
- 会话记录层：`prompt.id` 只盖在“用户消息”那种行上（用户输入行、以及工具结果回注的行，这两种在内部都算 `user` 类型），assistant 行不带。所以它是这次 prompt 在本地记录里的一个锚点，帮你定位起点，不是能把所有相关消息一网打尽的分组键。
- 追踪层：span 根本不带 `prompt.id`。一次交互内部的父子关系不靠 id，靠“谁是谁的父 span”这种树形归属来表达（全都挂在 `claude_code.interaction` 这个根下，见 16.4）。它和事件、记录指的是同一次 prompt，纯粹因为三者是同一处代码同时开的。
- 指标层：故意一个 prompt 级 id 都不带（`events.ts:49` 注释直说，否则会 unbounded cardinality）。

离散层用 id 关联、因果层用树形归属、聚合层拒绝 id——这条分界是整套设计的第一性原则。记住它，后面每一层都在贯彻它。

下面四节分头拆这四层。

---

## 16.2 指标：一个开关，两条管线

指标面有个容易看漏的地方：它同时喂两条互不相干的管线，各由不同的开关控制。

一条是给你自己的。你设 `CLAUDE_CODE_ENABLE_TELEMETRY`，CC 就把指标按标准 OTLP 协议送到你自己的 Prometheus 或 collector（`instrumentation.ts:324`），企业拿它给团队做用量看板。另一条是给 Anthropic 的：面向 API 客户、企业版、团队版，指标会送进 Anthropic 自己的 BigQuery（`isBigQueryMetricsEnabled`，`instrumentation.ts:336`），供官方做产品分析。

两条独立。哪怕你没开 `CLAUDE_CODE_ENABLE_TELEMETRY`，BigQuery 那条也可能在跑——它由组织级的 opt-out 单独控制。想清楚这点，才不会把“我没开遥测”误当成“什么都没上报”。

### 别让上报把主流程拖垮

BigQuery 这条在每次导出前都要确认一句“这个组织允许上报吗”。最直白的写法是每次导出都打一次 API，但 `claude -p` 这种一次性调用一天可能跑几百次，那样启动路径和网络都受不了。CC 的做法是拿两级缓存把它压成“大约一天一次 API”：进程内一个一小时 TTL 的内存缓存去重，磁盘上再放一个 24 小时 TTL 的缓存跨进程存活（`metricsOptOut.ts:22`，注释原话就是这级缓存把 N 次 `claude -p` 折叠成每天约一次 API）。缓存新鲜就零网络返回，过期才后台异步刷新。上面还压了一道熔断：一旦“非必要流量被全局关闭”，直接返回未启用、在消费端就把导出停掉（`metricsOptOut.ts:57`）。

这背后是一条能推广的工程判断：可观测性本身不能成为负担。观测组件必须能被廉价地关掉、能容忍读到陈旧值、且默认不阻塞主流程。

### 八个指标，一个稳定的底座

CC 内部注册的指标只有八个，都是只增不减的计数器（`bootstrap/state.ts:955`）：启动的会话数、改动的代码行数、创建的 PR 数与 commit 数、会话花费（美元）、token 数、代码编辑工具的批/拒计数、活跃秒数。

两个细节值得留意。一是这八个和官方 [Monitoring 文档](https://code.claude.com/docs/en/monitoring-usage)的清单逐条对齐——这是个难得的“快照即当前”的稳定点，下一节会看到事件清单正好相反，快照之后猛涨。二是它们全用计数器而不用瞬时值（gauge）：计数器对采样丢失、进程重启、乱序到达都更鲁棒，聚合时只要做差值，CC 甚至默认把时序偏好设成 delta（`instrumentation.ts:113`，注释说这才是更合理的默认）。

还要注意指标只带“形状”不带内容。token 计数带 `type`（input/output/cacheRead/cacheCreation）和模型名，但绝不带 prompt 文本；代码行计数带 added/removed，但绝不带文件路径或代码。这不是疏忽，是刻意，理由见 16.8。

### 把基数当旋钮

每条指标该带哪些公共属性，本身也是可配的——因为“多带一个属性”往往就等于“时序库多切一个维度”。CC 把最危险的几个做成开关，默认值就透着克制（`telemetryAttributes.ts:10`）：`session.id` 默认带，`app.version` 默认不带。

`app.version` 默认关的理由，和指标拒绝 `prompt.id` 是同一个：CC 周更，要是每个版本号都成为一个新维度，时序库会被版本切得粉碎。把这些交给 env 开关，等于把“我要多细的归因”和“我能扛多大的基数”之间的取舍权交给运维。可观测性设计里，这个取舍会一次次出现。

（另有一个不起眼但值得学的细节：OTLP 的三种协议 exporter 加起来约 1.2MB，CC 没在启动时全静态导入，而是在协议分支里按需 `await import()`——把“只有开了遥测、且用某协议才需要”的重依赖挪出冷启动关键路径，`instrumentation.ts:3`。CLI 对启动延迟很敏感，这类纪律随处可见。）

---

## 16.3 事件：一处代码，两条事件流

理解 CC 遥测的第二个关键，是“内部分析”和“客户可观测”是两条并行的事件流，而且常常从同一处代码同时发出。API 成功那一瞬间，这处代码连发三样东西：一条内部分析事件（去 Anthropic 的 Statsig/Datadog/BigQuery），一条客户 OTel 事件（去你自己的 OTLP 后端），再结束这次请求对应的追踪 span。

```typescript
// services/api/logging.ts（API 成功时）
logEvent('tengu_api_success', { model, inputTokens, outputTokens, costUSD, … })  // 内部分析
void logOTelEvent('api_request', { model, input_tokens, output_tokens, … })       // 客户可观测
endLLMRequestSpan(llmSpan, { success: true, inputTokens, … })                     // 结束 trace span
```

两条流分工清楚。内部这条（名字都以 `tengu_` 打头）去向 Anthropic，供官方做产品分析、A/B、行为回归，数量上有数百个事件名，由 feature flag 加采样门控。客户那条（`logOTelEvent`）去你自己的后端，供你给团队做用量和健康看板，就官方文档化的一二十类，由 `CLAUDE_CODE_ENABLE_TELEMETRY` 门控。

内部这条有个有意思的类型级护栏：`logEvent` 不收裸字符串，任何要传的字符串都得显式断言成一个名字很长的类型——`AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS`（`services/analytics/index.ts:19`）。等于用编译期类型把“别把代码或文件名误传进分析后台”变成一条过不了 CI 的硬约束。真要传 PII，得走 `_PROTO_` 前缀，路由进有访问控制的 BigQuery 专列，并在扇出到 Datadog 前统一剥掉（`index.ts:45`）。

### 每条客户事件的骨架

客户侧事件都走 `logOTelEvent`（`events.ts:21`），它给每条事件盖三个信封字段：事件名、时间戳，和一个会话内单调递增的 `event.sequence`。

`event.sequence` 值得单说一句，因为容易误解：它是个发射计数器，每发一条加一，作用是让后端在批处理和网络打乱了到达顺序之后，还能把一次 prompt 内的事件按发射先后排回来。它不是墙钟时刻——并发或重叠的 API 与工具活动谁真正先发生、各持续多久，那要看时间戳和下一节的 span 耗时。`event.sequence` 只保证一件事：离散事件的发射次序可恢复。

### 快照里有哪些事件，如今又多了多少

在 v2.1.88 快照里，实际发出的客户事件是这几类：`user_prompt`、`api_request`、`api_error`、`tool_result`、`tool_decision`，加上 beta 详细追踪下的 `system_prompt`、`tool`，以及 hook 的开始/结束和一条问卷事件。

当前官方文档的清单已经明显扩张，多出 `assistant_response`、`compaction`、`skill_activated`、`permission_mode_changed`、`api_request_body`/`api_response_body`、`mcp_server_connection`、`plugin_loaded` 等一批。

> 一条口径说明：本章 metric 清单（8 个）在快照与官方文档间稳定一致，但事件清单在 v2.1.88 之后长了不少。凡提到那些在快照里 grep 不到的标识符（`assistant_response`、`OTEL_LOG_RAW_API_BODIES` 等），一律以官方 [Monitoring 文档](https://code.claude.com/docs/en/monitoring-usage)为准，注明是“据官方文档”，不当成从快照源码里直接读到的事实。

### 用 api_request 看一次调用

拿最能回答读者的 `api_request` 说（`logging.ts:718`）。它在每次 API 成功后发一条，带模型名、input/output/缓存读/缓存写各多少 token、花了多少钱、耗时多久、快慢档。把一次 prompt 里所有带同一 `prompt.id` 的 `api_request` 按 `event.sequence` 排好，你就拿到这条 prompt 的一份模型调用流水：一共打了几次 API、各花多少 token 和钱、命中多少缓存、多久。`tool_decision` 和 `tool_result` 再补上“工具被怎么放行/拦截”和“工具跑了多久、成没成、结果多大”。三类拼起来，一次 prompt 的对外动作总账就有了。

但要老实标一条边界。在 v2.1.88 快照里，这些事件是按 prompt 关联、不是按 turn 关联的：`api_request` 不带 turn 序号，`tool_decision` 和 `tool_result` 也不带 `tool_use_id`（当前版本才给它们加上）。所以“哪个 tool_result 对应哪次 api_request、哪个 turn”这种精确因果，光靠事件字段在快照里拼不出来——那是下一节 span 树和会话记录的父子边负责的事。事件层给的是“这次 prompt 的有序动作流水”，因果树留给追踪层。

---

## 16.4 追踪：一次交互的算子树，加一份人人都有的记录

这是全章重心，也最贴近“对一次任务做 EXPLAIN”的诉求。它由两套东西共同承担：一套是 OpenTelemetry 的 span 树（精确因果，但默认关、还在 beta），一套是本地会话记录（默认就开、人人都有）。

### (A) span 树：把一次交互建成一棵算子树

CC 定义了六种 span，它们的父子关系摆出来，正好就是一棵 EXPLAIN 算子树：

```
claude_code.interaction              根：一次用户 prompt 到回复的完整周期
  ├─ claude_code.llm_request         每个 turn 的一次模型调用
  ├─ claude_code.tool                一次工具调用
  │    ├─ …tool.blocked_on_user      卡在“等你批权限”的墙钟时间
  │    └─ …tool.execution            工具真正执行的时间
  └─ claude_code.hook                一次 hook 执行（仅 beta 详细追踪）
```

（类型定义见 `sessionTracing.ts:49`。）根就是 16.1 那个在 prompt 一进来时开的 `startInteractionSpan`，结束时补上整段耗时。父子关系怎么建立的？靠 `AsyncLocalStorage`：交互 span 存进去，之后开子 span 时从里面取出当前父 span——这样哪怕中间隔着好几层异步 await，子 span 也能正确挂到当前交互底下，而不用一路手动传句柄。

三个设计细节特别能说明“追踪要做对不容易”。

第一，把“等你批权限”单独计时。为什么给这段专门开一个 `tool.blocked_on_user` span？因为一次 Edit 慢，可能是模型慢、可能是磁盘慢，也可能纯粹是你去倒了杯咖啡。把“等人”从“执行”里剥出来，才能在追踪里一眼看清墙钟时间到底花在哪儿——这直接决定你该去优化模型、优化 IO，还是优化工作流。这段结束时还会记下批还是拒、谁批的，数据来自权限层（见 16.6）。

第二，并发请求必须显式传 span。一次交互里常有好几个模型请求同时在打（主线程、预热、话题分类器、路径抽取器）。代码注释专门警告：回填响应时如果不把当初那个 span 显式带上，就可能挂到“恰好最后开的那个”span 上，造成响应错配（`sessionTracing.ts:342`）。这是并发追踪里最经典的坑，CC 用“显式传句柄”而不是“隐式取最近”来根治。

第三，给 span 泄漏兜底。正常路径会及时结束并删除 span，但流被中断、turn 中途抛异常时，span 可能永远不 end。CC 用弱引用存活跃 span，再挂一个半小时的后台清理，把没人引用或超时的强制回收——观测设施自己不能变成内存泄漏源。

每个 span 结束都带耗时，模型 span 还带 token 明细。于是这棵树天然就是一份带耗时和消耗的 EXPLAIN：哪个 turn、哪次工具、哪段等待最贵，一目了然。

那它为什么默认关、还挂着 beta？因为它又贵又敏感。开启要同时满足“遥测已开”和“增强追踪已开”，后者还得编译期把这个特性编进去、运行时再放行。更详细的 beta 路径连 system prompt、模型输出都往 span 里写，门槛更高、还带脱敏。span 数量随工具调用线性涨、又背着不少属性，默认开会同时压上性能和隐私两笔账。

### (B) 会话记录：那份默认就在的追踪

如果说 span 树是“要专门开、给后端看”的追踪，那会话记录就是“day-0 就在、每个人本地都有”的那份。前者要你开遥测配后端，后者每次运行都自动往 `~/.claude/projects/<把工作目录转义成的目录名>/<会话 id>.jsonl` 写。`/resume` 读它，社区几乎所有分析工具也读它。对绝大多数人，这才是真正用得上的那份 trace。子 agent 另写自己的文件，每条委派链的追踪互相分开。

它本质是一个用 `parentUuid` 串起来的链表：每写一条消息，都盖上一批字段，其中 `parentUuid` 指向上一条。三个精妙处：

- 工具结果的父指针，指向发起它的那条 assistant 消息，而不是简单的“物理上一条”。代码专门处理这点（`sessionStorage.ts:1031`）。于是某个工具结果能精确回溯到“哪个 assistant turn 里的哪次调用请求了它”——因果是显式连边，不是靠时间顺序去猜。
- 压缩边界断物理链、但留逻辑链。上下文压缩会把一段历史换成摘要，这时 `parentUuid` 置空（物理断开，好让 prompt 缓存稳定），但另存一个 `logicalParentUuid` 保住原来的父指针，读的时候再桥接。压缩不破坏追踪的逻辑连续性。
- `promptId` 只盖在用户消息上，正是 16.1 那个锚点在硬盘上的落点。

那怎么从这份记录里重建“一次 prompt 的多个 turn”？主力是沿 `parentUuid` 走连边：从任意一条消息回溯到根，或从起点往下顺子边展开，就能把 assistant turn、它发起的工具调用、以及回来的结果层层连起来。`promptId` 在这里是定位锚点，不是分组键——它只在 user 行上，所以你用它找到某次 prompt 的起点，再靠 `parentUuid` 把这次的 assistant 和工具因果走出来；单纯“按 promptId 分组”会漏掉不带它的 assistant 行，收不齐。这就是会话记录版的 EXPLAIN，而且它默认就躺在你硬盘上，一个遥测都不用开。

用户也不必手动去解析 JSONL：`/cost` 汇总本次会话的 token、成本、时长（见 16.5），`/status`、`/context` 看别的维度，`--debug` 打开调试日志。社区那些按 transcript 算用量、抓请求的工具，也都建在这份记录或其等价数据上。

### 活例子：`/goal` 和 `/loop` 怎么被追踪

抽象讲完，用两个真实功能落一下地。以下来自对 2.1.201 客户端的逆向（静态抽字符串加抓包），是快照之后的东西，单独说明，跟前面基于 v2.1.88 快照的内容分开、不混为一谈。

`/goal`（让 Claude 盯着一个目标反复干到达成）自带追踪字段。你设一个目标后，会话记录里会挂一条 `goal_status`，字段是条件、迭代了几轮、耗时、烧了多少 token、达成没——每一轮的进度都累计进去。它的机制是一个每轮结束就触发的 Stop hook，派一个独立的小模型判“达没达成”，判词和计数都落进这条记录。

`/loop`（按间隔或自定节奏反复跑一个 prompt）则靠一串带语义的事件追踪每次触发：调度了下一次唤醒、这次唤醒结束了一个 turn、太久没动被回收、保活触发、循环结束，再加上后台调度守护进程的一族事件。每次循环重投同一个 prompt，都在这条事件流里留痕。

两个例子说明同一件事：CC 的“跨多个 turn 的一次任务”不是黑盒——要么落成会话记录里的结构化字段（goal），要么落成一串带语义的事件（loop），都能重建“这次自治跑了几轮、每轮什么状态”。这两个功能的完整逆向见[第 17 章](17-autonomy-goal-loop.md)。

---

## 16.5 成本：不是一个总数，是一路累加

`/cost` 那块摘要背后，是每次 API 成功就即时累加的账。每成功一次，这次的用量就累进按模型分的汇总，同时喂给两个计数器——花费一个，token 一个（`cost-tracker.ts:291`）。

token 被拆成 input、output、缓存读、缓存写四类分开计，因为四类单价不同：缓存读命中远比新输入便宜，不分开就算不准钱。CC 甚至给内部的 advisor 子调用单独计费、单发一条事件，确保派生调用的成本不漏。

这笔账还能跨会话接续：成本状态存进项目配置，`/resume` 同一个会话时按会话 id 恢复，续跑不会从零开始。进程退出时打印总账并存盘，就是你在终端看到的那块“总花费 / API 与墙钟耗时 / 代码改动 / 各模型用量”。

所以这一节回答的是 EXPLAIN 里“每一环消耗多少资源”：token 和钱不是一个事后总数，而是每次调用即时累加、按模型和类型分维、还能跨 resume 接着算的运行量。

---

## 16.6 权限决策：把“为什么放行”也记下来

EXPLAIN 里还有一环——每个动作是否正常、有没有被拦。CC 把每一次工具权限决策都记成可观测数据，核心是一个叫 `logPermissionDecision` 的函数，它是所有“批准 / 拒绝”的唯一出口，一次决策扇出到四个地方（`permissionLogging.ts:181`）：按来源分成不同名字的内部分析事件（做审批漏斗），给代码编辑工具的计数器加一，把决策写回工具上下文供追踪层读，再发一条客户 OTel 事件。

有观测价值的是它把“谁批的”结构化成了 `source` 字段：配置里 allowlist 自动放的、hook 放的、你点“总是”或“这次”放的、分类器放的、你拒的、你中止的。安全复盘时，你能一眼分清“这次危险操作是人手动放的，还是规则自动放的”——这往往是决定性的。它还把“等人”量化成一个毫秒数，只在真弹了确认框时才记，自动放行不计；这个数和追踪层那个 `tool.blocked_on_user` span 是同一件事的两种呈现。而且决策被写回工具上下文，所以那个 span 结束时能标上准确的批/拒和来源——三层在权限这一点上是打通的。

权限模型本身（`ask`/`allow`/`deny`、分类器、hook 决策）详见[第 12 章](11-permission-security.md)，这里只关心它怎么被观测。

---

## 16.7 一方 / 内部专用的设施：一条诚实的边界

不是所有观测设施都给外部用户。讲清楚哪些你能用、哪些在编译期就被裁掉或只在 Anthropic 内部，才不会误判。

`ant-trace` 在外部包里是个空壳。它在快照里的实现就一行占位：永远返回“未启用”。这是编译期死代码消除的活标本——外部构建里物理上没有真实现，只留一个永远关着的桩。顺带提醒一句方法论：在公开产物里搜不到某功能的完整逻辑，不等于它不存在，可能只是被编译期裁掉了。

Perfetto 本地追踪也一样，是 Ant-only、编译期门控的。它能把 span 导成 Perfetto UI 里的火焰图，听着很诱人，但源码头就写明“Ant-only”“external build 里被消除”，整个初始化裹在一个编译期特性开关里（`perfettoTracing.ts:2`、`:7`、`:260`）。也就是说，`CLAUDE_CODE_PERFETTO_TRACE=1` 只在这个特性被编进包时才有用，外部构建里这段已被裁掉，环境变量置了也白置。别把它当成外部用户随手能开的功能。

还有两处只值得一提：去 Anthropic BigQuery 的那条（16.2 讲过，组织 opt-out 门控，外部不经手）；以及一个把终端渲染帧率也纳入自观测的 FPS 追踪——一个 TUI 连自己画得流不流畅都记，是 UX 工程的细致处（渲染器细节见[第 14 章](12-user-experience.md)）。至于 beta 详细追踪，能把 system prompt、模型输出也写进 span，但有一张严格的可见性表：模型的思考原文是 ant-only，外部用户即使开满也拿不到；system prompt 全文只在这条路径、且每个唯一 hash 只发一次，避免重复上报大文本。

> CC 反过来对逆向者埋的那些防御仪器（假工具注入、隐写指纹之类）是另一个话题，本书不展开。

---

## 16.8 隐私边界：能看到“形状”，看不到“内容”

可观测性和隐私天生拉扯：记得越多越好查，也越危险。CC 的总原则一句话——默认只导出“形状”，内容一律要你显式开。

“形状”指长度、token 数、时长、模型名、工具名、决策类型这类元数据；“内容”指 prompt 原文、模型回复、工具的输入输出、代码和文件。默认走前者，后者得逐项开：

| 内容 | 开关（env） | 默认 |
|---|---|---|
| 用户 prompt 原文 | `OTEL_LOG_USER_PROMPTS` | 打码，只留长度 |
| 助手回复原文 | `OTEL_LOG_ASSISTANT_RESPONSES` | 脱敏，只留长度 |
| 工具参数 / 命令 | `OTEL_LOG_TOOL_DETAILS` | 省略 / 脱敏 |
| 工具输入输出 | `OTEL_LOG_TOOL_CONTENT` | 省略 |
| 原始 API 请求 / 响应体 | `OTEL_LOG_RAW_API_BODIES` | 不导出 |
| system prompt 全文 | 仅 beta 详细追踪 | 不进普通导出 |
| 模型思考原文 | 仅内部 | 外部永不导出 |

（第 1、3、4 行——用户 prompt / 工具参数命令 / 工具输入输出——在快照里有对应的脱敏代码；第 2 行“助手回复”（`OTEL_LOG_ASSISTANT_RESPONSES`）与第 5 行 `OTEL_LOG_RAW_API_BODIES` 同属快照之后新增，据官方文档。）

几条设计意图值得点破。

代码和文件内容永不进指标或事件。官方明说“原始文件内容和代码片段不进 metrics 或 events”，源码侧则用 16.3 那个长名字类型把它变成编译期约束。默认观测面里，你能看到“改了 N 行 TypeScript”，但看不到改了什么。

隐私门控和基数控制常常是同一个旋钮的两面。`prompt.id` 只进事件、不进指标；`app.version` 默认关——一个是怕泄可归因信息，一个是怕基数爆炸，但拧的是同一批开关。这不是巧合：往聚合后台少塞信息，既省成本又护隐私，两个诉求经常指向同一个决定。

子进程这块有个容易搞反的点：默认（普通本地会话）CC 把整份环境原样透传给它 spawn 的子进程，`OTEL_*` 也在内；只有当 claude-code-action 把 `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` 置真——也就是会话被暴露给不可信内容的 CI 场景（配了 `allowed_non_write_users`）——CC 才从子进程环境里剥掉那 4 个携带 `Authorization=Bearer` 的 OTLP header 变量（`OTEL_EXPORTER_OTLP_HEADERS` 那一批），免得凭证被 shell 展开外泄给第三方；而后端地址 `OTEL_EXPORTER_OTLP_ENDPOINT` 任何情况下都不剥离。但据当前官方文档，追踪激活时它会把“这是哪条 trace”的关联信息（W3C 的 `TRACEPARENT`）注入 Bash/PowerShell 子进程，好让子进程的 span 挂到同一条 trace 上。传的是关联信息，不是导出凭证，两者别混。（这两个环境变量在快照里 grep 不到，属快照之后新增，据官方文档。）

---

## 小结

Claude Code 的可观测性不是“打一堆 log”，而是一套分层、按用途裁剪、隐私优先的自省系统。它能正面回答“对一次任务做 EXPLAIN”：一次 prompt 的多个 turn、每 turn 的 API 与工具动作、各自的耗时和消耗、在哪等人、决策来自谁、有没有报错。四层各司其职——事件靠 `prompt.id` 归拢，追踪靠 span 的父子归属串起，会话记录靠 `parentUuid` 连边重建因果，指标则一概拒绝这些高基数 id 好保住自己能被聚合。

几条反复出现的设计取舍，值得单独记住：

| 取舍 | 为什么 |
|---|---|
| 四层分开，不塞一个大日志 | 聚合、按条件查、看因果、完整重放是四类问题，各需一种数据结构 |
| 聚合层拒绝 `prompt.id` / 默认不带版本号 | 高基数维度会打爆时序库 |
| 指标全用计数器加 delta | 对丢失、重启、乱序都鲁棒，聚合只做差值 |
| 内部 `tengu_` 与客户 OTel 双流并行 | 官方产品分析与你自己的可观测互不干扰，从同一处并发 |
| 把“等你批权限”单独计时 | 墙钟耗时里“等人”和“执行”混在一起就没法归因 |
| 并发请求显式传 span 句柄 | 根治并发追踪的响应错配 |
| 弱引用加定时兜底清 span | 观测设施自己不能变成内存泄漏 |
| 会话记录用父子连边、结果指向发起它的调用 | 因果靠显式连边，不靠时间顺序猜 |
| opt-out 两级缓存加熔断 | 观测不能拖垮主流程：可廉价关、容忍陈旧读 |
| 默认只导“形状”、内容逐项开 | 用“默认脱敏加显式开启”化解可观测与隐私的张力 |

> 版本说明：本章基于 v2.1.88 泄露源码快照做走读，并与官方 [Monitoring 文档](https://code.claude.com/docs/en/monitoring-usage)交叉核对。8 个指标在快照与当前文档间稳定一致；事件清单快照之后大幅扩张，凡超出快照的标识符均标“据官方文档”。`/goal`、`/loop` 的追踪细节来自对 2.1.201 客户端的逆向（静态抽取加抓包），单独说明，跟快照里能直接读到的内容分开、不混为一谈。
