可观测性基础设施:Claude Code 如何观测自身运行

这篇文档只回答一个问题:

src/ 里的可观测性基础设施,到底是怎样分层、初始化、采集、脱敏并导出的?

先给结论:

  • 这套实现不是一个单点 telemetry 模块,而是四层协作:
    • src/services/analytics/:业务事件采集与分发
    • src/utils/telemetry/:OpenTelemetry 指标、日志、追踪与导出器
    • src/bootstrap/state.ts:全局 meter / logger / tracer / counter 状态中枢
    • src/context/stats.tsx:会话内轻量统计与本地落盘
  • 系统同时维护多种信号类型,而不是把所有信息都塞进同一条管线:
    • analytics event:面向产品行为、权限决策、功能使用
    • metrics:面向会话级计数、成本、token、活跃时长
    • logs / events:面向 OTel 事件记录和内部事件批量上报
    • traces:面向 interaction / tool / LLM request 的时序链路
  • 这套实现非常强调隐私边界:
    • 默认不允许任意字符串直接进入 analytics metadata
    • MCP/tool 名称、prompt 内容、workspace 路径都要经过显式门控
    • _PROTO_* 字段只允许流向 1P 特权后端,不会进入 Datadog 这类通用后端

可以先把整体链路记成下面这条:

调用点
  -> analytics / OTel API
  -> sink / provider / state
  -> exporter
  -> Datadog / 1P event logging / OTLP / BigQuery / Perfetto / 本地 session metrics

1. 边界:可观测性不是一个目录,而是一组协作层

如果只看目录名,很容易把可观测性理解成 services/analytics/。

但真实实现更接近“四层拼接”:

1.1 第一层:services/analytics/ 负责业务事件

这一层关注的是:

  • 用户做了什么
  • 权限如何被批准或拒绝
  • API 请求是否成功
  • 插件、Skill、MCP 等能力是否被启用

它的统一入口是 src/services/analytics/index.ts,核心 API 只有:

  • logEvent()
  • logEventAsync()
  • attachAnalyticsSink()

也就是说,这一层的抽象不是“日志行”,而是“业务事件”。

1.2 第二层:utils/telemetry/ 负责 OTel 信号

这一层处理的是 OpenTelemetry 语义下的三种核心信号:

  • metrics
  • logs / event
  • traces

对应的核心文件包括:

  • src/utils/telemetry/instrumentation.ts
  • src/utils/telemetry/events.ts
  • src/utils/telemetry/sessionTracing.ts
  • src/utils/telemetry/bigqueryExporter.ts
  • src/utils/telemetry/perfettoTracing.ts
  • src/utils/telemetry/betaSessionTracing.ts

它不是“业务埋点系统”的附庸,而是一套独立的运行时遥测基础设施。

1.3 第三层:bootstrap/state.ts 负责全局观测状态注入

src/bootstrap/state.ts 并不直接导出 exporter,但它保存了:

  • meter
  • sessionCounter
  • locCounter
  • prCounter
  • commitCounter
  • costCounter
  • tokenCounter
  • codeEditToolDecisionCounter
  • activeTimeCounter
  • loggerProvider
  • eventLogger
  • meterProvider
  • tracerProvider
  • statsStore

所以它是“观测运行时句柄”的集中注册表。

1.4 第四层:context/stats.tsx 负责会话内轻量统计

src/context/stats.tsx 这层不是标准 OTel,也不是远端 analytics。

它更像一个:

  • 进程内统计 accumulator
  • UI 渲染性能采样器
  • session 结束时落盘的本地 metrics store

这层解释了为什么仓库里既有 OTel counter,又有 StatsStore.observe()。

两者不是重复设计,而是分工不同。

2. 业务事件链路:logEvent 如何走到后端

2.1 src/services/analytics/index.ts 是统一入口

这一层最关键的设计是“先记事件,再挂 sink”。

logEvent() 和 logEventAsync() 不直接依赖 Datadog 或 1P logger,而是:

  1. 如果 sink 还没挂上,就把事件放进内存队列
  2. 等 attachAnalyticsSink() 被调用后,再异步 drain 这些事件

这意味着 analytics API 本身被做成了一个无依赖、低耦合的门面层:

  • 避免 import cycle
  • 允许启动早期先记录事件
  • 把真正的路由逻辑延迟到应用初始化完成之后

2.2 src/utils/sinks.ts 负责真正挂接 sink

initSinks() 会按顺序做两件事:

  1. initializeErrorLogSink()
  2. initializeAnalyticsSink()

这里的顺序不是随意的。errorLogSink 注释里明确要求它在 analytics sink 之前初始化,这样 analytics 路由过程中产生的问题也有地方可记。

2.3 src/services/analytics/sink.ts 负责 fanout

这个模块把业务事件从统一入口分发到两个后端:

  • Datadog
  • 1P event logging

它承担的不是单纯“转发”,而是整个 analytics dispatch policy:

  • shouldSampleEvent():按 event name 做采样
  • initializeAnalyticsGates():初始化 Datadog gate
  • isSinkKilled():按 sink 维度做 killswitch
  • stripProtoFields():在通用后端前剥离 _PROTO_* 字段

也就是说,真正的 analytics 规则集中在这里,而不是散落在调用点。

2.4 Datadog 与 1P 后端不是对称关系

src/services/analytics/datadog.ts 和 src/services/analytics/firstPartyEventLogger.ts 看起来都在“发事件”,但角色不同。

datadog.ts 的特点:

  • 只接受 allowlist 中的事件名
  • 只在 production 且 provider 为 firstParty 时发送
  • 事件会做 cardinality reduction
  • _PROTO_* 字段会在进入 Datadog 前被剥离
  • 以批量 HTTP POST 的方式发到 Datadog logs intake

firstPartyEventLogger.ts 的特点:

  • 使用独立的 LoggerProvider
  • 通过 FirstPartyEventLoggingExporter 批量发到 /api/event_logging/batch
  • 支持采样配置、批处理配置、GrowthBook refresh 后重建 provider
  • 失败事件可以落盘并重试

它们的关系更像:

  • Datadog:通用事件分析后端
  • 1P event logging:内部特权事件管线

2.5 初始化时序上,1P event logging 甚至早于完整 telemetry

src/entrypoints/init.ts 在 init() 里就会异步加载:

  • firstPartyEventLogger.js
  • growthbook.js

然后调用 initialize1PEventLogging()。

这样做的意义是:

  • 即使完整 OTel telemetry 还没初始化
  • 甚至 trust 后才初始化的那部分 telemetry 还未就绪
  • 1P internal event logging 也可以更早开始工作

这能减少启动早期业务事件丢失。

3. OTel 基础设施:metrics、logs、traces 如何装起来

3.1 src/utils/telemetry/instrumentation.ts 是 OTel 装配核心

这个文件负责:

  • 读取 OTEL_* 环境变量
  • 解析 metrics / logs / traces exporter 类型
  • 动态导入不同 OTLP exporter
  • 合并 resource 信息
  • 初始化 MeterProvider、LoggerProvider、BasicTracerProvider
  • 注册 shutdown / forceFlush 逻辑
  • 初始化 Perfetto tracing

这里有两个重要特征。

第一,它大量使用 lazy import。

目的很明确:

  • 避免 OpenTelemetry 和 gRPC 依赖在每次启动时都被提前加载
  • 只有真正启用某类 exporter 时,才把对应实现拉进来

第二,它把 customer OTLP telemetry 与 1P event logging 分开装配。

firstPartyEventLogger.ts 使用的是自己的 LoggerProvider,并不会注册为全局 provider;而 instrumentation.ts 初始化的是 customer-facing 的 OTel provider。

这是一条明确的数据隔离边界。

3.2 src/entrypoints/init.ts 负责“信号装配”和“状态映射”

initializeTelemetryAfterTrust() 是完整 telemetry 初始化的入口。

它的时序不是“程序一启动就立刻做”,而是:

  • 对 remote managed settings 用户,先等设置加载,再重新应用 env vars,再做 telemetry init
  • 对普通路径,则直接初始化
  • 在 headless + beta tracing 的场景下,还会走 eager init,避免首个 query 开始时 tracer 还没准备好

真正的初始化发生在 setMeterState():

  1. 动态导入 initializeTelemetry()
  2. 拿到 meter
  3. 基于 meter.createCounter() 构造 attributed counter 工厂
  4. 调用 setMeter() 把这些 counter 注册到 bootstrap/state.ts
  5. 补记一次 sessionCounter

所以 init.ts 的角色不是 exporter 本身,而是把 exporter 产出的运行时对象接入全局状态。

3.3 src/bootstrap/state.ts 是 OTel 运行时句柄的集中存储

setMeter() 会一次性构造多个标准计数器:

  • claude_code.session.count
  • claude_code.lines_of_code.count
  • claude_code.pull_request.count
  • claude_code.commit.count
  • claude_code.cost.usage
  • claude_code.token.usage
  • claude_code.code_edit_tool.decision
  • claude_code.active_time.total

除此之外,bootstrap/state.ts 还提供:

  • setLoggerProvider() / getLoggerProvider()
  • setEventLogger() / getEventLogger()
  • setMeterProvider() / getMeterProvider()
  • setTracerProvider() / getTracerProvider()

这使得业务代码不需要自己持有 provider,只需要从 state 中取句柄即可。

3.4 BigQuery metrics 是 metrics 管线的一部分,不是 analytics fanout 的一部分

src/utils/telemetry/bigqueryExporter.ts 实现了 PushMetricExporter,会把 OTel metrics 转成内部 API 可接受的 payload,然后发到 /api/claude_code/metrics。

它的特点是:

  • 要求 trust 已建立或处于 non-interactive session
  • 会检查组织级 metrics opt-out
  • 导出的是 metrics,不是业务事件
  • 使用 delta temporality,明确不走 cumulative

所以这条链路属于 metrics exporter,而不是 logEvent -> sink 那条业务事件链路。

4. 会话内统计:为什么还需要 StatsStore

4.1 src/context/stats.tsx 是一套轻量本地统计系统

这里的 StatsStore 支持四种操作:

  • increment()
  • set()
  • observe()
  • add()

其中 observe() 不是简单相加,而是维护一个 histogram:

  • count
  • sum
  • min
  • max
  • reservoir sample

最终还能导出:

  • p50
  • p95
  • p99

4.2 它的核心用途是会话内统计和本地持久化

StatsProvider 会在进程退出时把 store.getAll() 的结果写入配置中的 lastSessionMetrics。

所以它更像:

  • 本地 session summary
  • UI / TUI 运行时指标
  • 调试和回看辅助数据

而不是标准 OTel exporter 的替代品。

4.3 StatsStore 与 OTel counter 的职责不同

StatsStore 擅长:

  • 轻量、局部、进程内聚合
  • percentile 近似统计
  • 会话结束后本地落盘

OTel counter 擅长:

  • 远端导出
  • 标准语义指标
  • 带 attributes 的统一计量

所以可以把它们理解为:

  • StatsStore:本地运行态观测
  • OTel metrics:标准化外部观测

4.4 交互态会显式把 StatsStore 注入运行时

src/interactiveHelpers.tsx 的 getRenderContext() 会:

  1. createStatsStore()
  2. setStatsStore(stats)
  3. 把 stats 交给 App 组件树

src/components/App.tsx 再通过 StatsProvider 注入 React 树。

这说明 StatsStore 是交互态 runtime 的一等公民,而不是某个页面私有的小工具。

5. Tracing 与 OTel 事件:运行链路如何被展开

5.1 src/utils/telemetry/sessionTracing.ts 负责 span 生命周期

这里定义的核心 span 类型包括:

  • interaction
  • llm_request
  • tool
  • tool.blocked_on_user
  • tool.execution
  • hook

这说明 tracing 不是只关心 API,而是把一次完整交互拆成多个运行阶段。

这个模块还做了几件非常关键的事:

  • 用 AsyncLocalStorage 保存当前 interaction / tool 上下文
  • 给 orphan span 做 TTL 清理,避免内存泄漏
  • 同时兼容 OTel tracing 与 Perfetto tracing
  • 在 enhanced telemetry 和 beta tracing 两种模式下复用同一套 span 入口

5.2 src/utils/telemetry/events.ts 负责 OTel 事件日志

logOTelEvent() 不是业务 analytics,而是 OTel event record。

它会为每条事件补充:

  • 通用 telemetry attributes
  • event.name
  • event.timestamp
  • event.sequence
  • prompt.id

此外还有一个非常重要的限制:

  • workspace.host_paths 只写入 event attributes,不进入 metric dimensions

这体现了仓库对高基数路径字段的谨慎处理。

5.3 代表性调用点 1:用户输入

src/utils/processUserInput/processTextPrompt.ts 在处理用户输入时同时做了三件事:

  1. startInteractionSpan(userPromptText)
  2. logOTelEvent('user_prompt', ...)
  3. logEvent('tengu_input_prompt', ...)

这非常能说明该仓库的设计风格:

  • 同一个用户动作,可能同时产出 trace、OTel event、analytics event
  • 但它们分别进入不同管线,承担不同用途

5.4 代表性调用点 2:API 请求

src/services/api/logging.ts 在 API 成功路径里会:

  • 记录 analytics 事件,例如 tengu_api_query、成功/失败事件
  • 记录 OTel 事件 api_request
  • 结束 LLM request span,并把 token、TTFT、输出信息挂到 span 上

这里可以清楚看到:

  • analytics 负责产品/行为语义
  • OTel event 负责结构化事件
  • tracing 负责时序与上下文

5.5 代表性调用点 3:工具权限与执行

src/hooks/toolPermission/permissionLogging.ts 和 src/services/tools/toolExecution.ts 共同覆盖了工具权限决策链路。

这里会同时写入:

  • analytics event,例如批准/拒绝事件
  • OTel event tool_decision
  • code edit tool counter

而 toolExecution.ts 还会在 headless 等非交互路径下补发这些信号,避免因为没有走 UI permission path 而丢观测数据。

这说明观测逻辑并不是只写在 UI 交互层,而是会在执行层补齐。

6. 隐私与数据治理:哪些内容能上报,哪些不能

6.1 metadata.ts 是 analytics 元数据的中心治理层

src/services/analytics/metadata.ts 做的不只是“补字段”,更像一个集中式 policy 层。

它负责:

  • 构造公共 metadata
  • 识别运行环境、平台、subscription、agent 身份
  • 处理 MCP/tool 名称脱敏
  • 处理文件扩展名提取
  • 处理 bash 命令里可安全提取的扩展名
  • 控制 tool input 在 telemetry 中的截断与深度

这比“调用点自己拼 metadata”要成熟得多。

6.2 类型标记本身就是一层制度约束

src/services/analytics/index.ts 和 metadata.ts 都定义了 marker type,例如:

  • AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  • AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED

这些类型不会在运行时做校验,但在代码层表达了一个重要制度:

  • 普通 analytics metadata 默认不应携带代码、路径或敏感原文
  • 只有被显式标记为 PII-tagged 的字段,才允许进入特定后端

6.3 _PROTO_* 是一条双通道设计

这套实现里最关键的隐私机制之一,就是 _PROTO_* 字段。

它的工作方式是:

  1. 调用点可以把原始值放进 _PROTO_*
  2. stripProtoFields() 会在发往 Datadog 等通用后端前剥离这些字段
  3. firstPartyEventLoggingExporter 会识别并提升这些字段,送进 1P 特权 proto 列

所以同一条事件可以同时拥有:

  • 通用后端可见的脱敏字段
  • 仅 1P 后端可见的原始字段

这就是典型的 twin-column / dual-path 设计。

6.4 MCP/tool 名称默认不是随便上报的

默认情况下:

  • MCP tool 名称会被 sanitizeToolNameForAnalytics() 归一成 mcp_tool
  • 只有满足特定条件时,才会通过 mcpToolDetailsForAnalytics() 记录更细颗粒度的 MCP server / tool 名称

这些条件包括:

  • local-agent 模式
  • claude.ai proxy connector
  • official registry 中的 MCP URL

也就是说,用户自定义 MCP 配置默认不会被当成普通低敏字段直接上报。

6.5 prompt、tool input、workspace path 都有显式门控

几个典型例子:

  • src/utils/telemetry/events.ts
    • 只有 OTEL_LOG_USER_PROMPTS=1 时才会记录真实 prompt,否则写 <REDACTED>
  • src/services/analytics/metadata.ts
    • 只有 OTEL_LOG_TOOL_DETAILS=1 时才会记录序列化后的 tool input
    • 而且会做长度截断、深度限制、键过滤
  • src/utils/telemetry/events.ts
    • workspace.host_paths 只进入 event,不进入 metric dimensions

这说明仓库对“可观测性”和“可泄漏性”是明确区分的。

7. 初始化顺序:这套基础设施何时变得可用

把关键顺序串起来,大致是这样:

程序启动
  -> init()
  -> 异步启动 1P event logging 与 GrowthBook
  -> preAction / setup 挂 error log sink 和 analytics sink
  -> trust 建立后执行 initializeTelemetryAfterTrust()
  -> initializeTelemetry()
  -> setMeterState()
  -> bootstrap/state 注册 meter / logger / tracer / counters
  -> 调用点开始持续写入 analytics / OTel / traces

这里有三个时序点值得特别记住。

第一,analytics API 可以早于 sink 调用。

因为 logEvent() 会先排队,等 sink 挂载后再 drain。

第二,1P event logging 可以早于完整 OTel telemetry。

因为 init() 里就已经启动了它。

第三,完整 telemetry 初始化要等 trust 和 remote managed settings 条件满足。

这也是为什么 initializeTelemetryAfterTrust() 不只是一个简单的“开关函数”,而是一段带条件和时序语义的编排逻辑。

8. 总结:这是一套分层而非单点的可观测性体系

最后可以把整个体系压缩成一句话:

这个仓库把“可观测性”拆成了三条主链路,再用全局 state 把它们接在一起:

  • services/analytics/:面向业务事件与产品行为
  • utils/telemetry/:面向 OTel 指标、事件、追踪与导出
  • context/stats.tsx:面向交互态本地统计与会话总结

它们共享一些调用点,但不会被粗暴混成一个系统。

所以如果要理解仓库里的 observability,最重要的不是记住某个 exporter,而是先分清三个问题:

  1. 这是 analytics event、metric、OTel event,还是 trace?
  2. 它进入的是通用后端、1P 特权后端,还是只留在本地 session?
  3. 它在上报前经过了哪些脱敏、采样、gate 和 killswitch?

把这三个问题想清楚,src/ 里的可观测性实现就基本能读通了。