Claude Code 源码解剖

Claude Code 的可观测性与版本演进

从 logEvent、AnalyticsSink、PII 类型标记、Datadog 允许列表、1P Exporter、OTel Span、Perfetto、优雅关闭和成本追踪拆解 Claude Code 如何让 Agent 行为可解释、可恢复、可演进。

第 18 章 22 分钟

Claude Code 的可观测性与版本演进

这一章要解决的问题是:一个运行在用户本机的 CLI Agent,怎么知道自己到底发生了什么?模型为什么变慢、工具为什么失败、权限为什么拒绝、缓存为什么断、进程退出前遥测有没有送出去,这些都不能靠“用户感觉”。

Claude Code 的答案是一套客户端遥测管线。它不是简单 console.log,而是从 logEvent() 入口、sink 附着、采样、PII 过滤、Datadog/1P 双路分发、OpenTelemetry、磁盘重试、调试日志、Perfetto trace、优雅关闭到成本追踪的完整链路。

核心直觉

Agent 的可观测性不是为了事后好看,而是为了把随机决策链变成可对比的工程证据。没有事件、span、成本、缓存和权限记录,就无法判断一次行为变化到底来自模型、提示词、工具、权限、缓存、网络还是版本 flag。

先看源码入口

源码定位关键符号负责什么
services/analytics/index.tslogEvent()eventQueueattachAnalyticsSink()全局事件入口,支持 sink 尚未初始化时先排队
services/analytics/sink.tslogEventImpl()shouldSampleEvent()stripProtoFields()采样、PII 字段剥离、Datadog 和 1P 双路分发
services/analytics/metadata.tssanitizeToolNameForAnalytics()MCP 工具名、文件扩展名等敏感维度降级
services/analytics/firstPartyEventLogger.tsBatchLogRecordProcessor1P OpenTelemetry logger 管线
services/analytics/firstPartyEventLoggingExporter.tsFirstPartyEventLoggingExporter批次分片、退避、401 降级、磁盘持久化和启动重传
services/analytics/datadog.tsDATADOG_ALLOWED_EVENTSgetUserBucket()实时告警通道、事件允许列表、用户桶
services/analytics/sinkKillswitch.tstengu_frond_boric远程关闭 Datadog 或 1P sink
services/api/logging.tstengu_api_querytengu_api_successtengu_api_errorAPI 请求三事件模型
services/api/withRetry.tstengu_api_retryAPI 重试原因、退避和状态码遥测
services/tools/toolExecution.tstengu_tool_use_*工具成功、失败、取消、权限拒绝事件
services/api/promptCacheBreakDetection.tsPreviousStateperToolHashesPrompt Cache 断点检测和原因归因
utils/debug.tsutils/diagLogs.tsutils/errorLogSink.tsdebug / diagnostic / error logs三条本地诊断通道
utils/telemetry/sessionTracing.tsinteraction / llm_request / tool spansOTel 分布式追踪和 AsyncLocalStorage 上下文传播
utils/telemetry/perfettoTracing.tsChrome Trace Eventant-only 可视化时间线
utils/gracefulShutdown.tsgracefulShutdown()forceExit()退出时恢复终端、执行 hooks、刷新遥测
cost-tracker.tsStoredCostStateaddToTotalSessionCost()token、成本、工具耗时和代码行变更追踪

这一章的主线就是:事件如何进入管线,如何安全地送出去,如何在失败和退出时不丢,以及如何支撑版本演进判断。

总流程图

flowchart TD A["任意模块 logEvent()"] --> B{"sink 已附着?"} B -->|否| C["eventQueue 暂存"] C --> D["attachAnalyticsSink()"] D --> E["queueMicrotask 排空"] B -->|是| F["AnalyticsSink.logEvent()"] E --> F F --> G["shouldSampleEvent()"] G -->|丢弃| H["结束"] G -->|通过| I["添加 sample_rate"] I --> J["Datadog 通道<br/>stripProtoFields() + 允许列表"] I --> K["1P 通道<br/>OTel BatchLogRecordProcessor"] K --> L["FirstPartyEventLoggingExporter"] L --> M{"发送成功?"} M -->|是| N["api.anthropic.com<br/>event_logging/batch"] M -->|否| O["~/.claude/telemetry<br/>失败批次落盘"] O --> P["启动时 retryPreviousBatches()"] P --> L Q["API / Tool / Cache / Shutdown"] --> A R["OTel Tracing"] --> S["interaction / llm_request / tool spans"] T["Debug / Diagnostic / Error logs"] --> U["本地 JSONL 或 log 文件"]

这张图说明一件事:Claude Code 的可观测性首先要解决 CLI 环境的现实问题:sink 可能没初始化、网络可能失败、进程可能退出、用户数据不能乱发。

事件入口:队列和附着模式

services/analytics/index.ts 是全局入口。这个文件刻意没有依赖,避免循环导入,让任何模块都能安全记录事件。

const eventQueue: QueuedEvent[] = []
let sink: AnalyticsSink | null = null

export function logEvent(
  eventName: string,
  metadata: LogEventMetadata,
): void {
  if (sink === null) {
    eventQueue.push({ eventName, metadata, async: false })
    return
  }
  sink.logEvent(eventName, metadata)
}

这段代码证明:Claude Code 允许启动早期就记录事件。遥测后端尚未附着时,事件不会丢,只是进入 eventQueue

附着后,队列通过微任务排空。

if (eventQueue.length > 0) {
  const queuedEvents = [...eventQueue]
  eventQueue.length = 0
  queueMicrotask(() => {
    for (const event of queuedEvents) {
      if (event.async) {
        void sink!.logEventAsync(event.eventName, event.metadata)
      } else {
        sink!.logEvent(event.eventName, event.metadata)
      }
    }
  })
}

这段代码证明:排空不阻塞启动路径,同时保持同步事件和异步事件的语义差异。

分发层:先采样,再分两路

实际分发在 sink.ts

function logEventImpl(eventName: string, metadata: LogEventMetadata): void {
  const sampleResult = shouldSampleEvent(eventName)
  if (sampleResult === 0) {
    return
  }
  const metadataWithSampleRate =
    sampleResult !== null
      ? { ...metadata, sample_rate: sampleResult }
      : metadata
  if (shouldTrackDatadog()) {
    void trackDatadogEvent(eventName, stripProtoFields(metadataWithSampleRate))
  }
  logEventTo1P(eventName, metadataWithSampleRate)
}

这段代码证明了三个设计点:

  • 采样发生在分发前,避免两个通道看到不同样本。
  • 采样率写回 metadata,方便下游校准统计。
  • Datadog 先调用 stripProtoFields(),1P 通道保留完整 metadata 进入内部管线。

远程熔断在 sinkKillswitch.ts

const SINK_KILLSWITCH_CONFIG_NAME = 'tengu_frond_boric'

配置值可以关闭 Datadog 或 1P。这个命名故意不直接暴露含义,但工程作用很明确:如果某个事件意外携带敏感字段,可以不用发布新版本,直接远程止血。

隐私保护:用类型系统逼开发者停一下

Claude Code 不让 logEvent metadata 随便带字符串。字符串最容易包含代码、路径、用户输入和配置名。源码用两个 never 类型作为显式声明。

export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED = never

never 不能自然构造,开发者必须写 as ... 才能传字符串。这段类型名本身就是审查清单:你真的确认这不是代码或文件路径吗?如果是 PII,是否已经用 _PROTO_ 标记?

_PROTO_* 字段走分级路由。Datadog 永远剥离,1P Exporter 会把已知字段提升到 proto 顶层,再对剩余字段再次 stripProtoFields()

const {
  _PROTO_skill_name,
  _PROTO_plugin_name,
  _PROTO_marketplace_name,
  ...rest
} = formatted.additional
const additionalMetadata = stripProtoFields(rest)

这段代码证明:即使未来新增了未识别的 _PROTO_* 字段,也会在 rest 里被再次剥离,降低泄漏风险。

工具名和扩展名也要降基数

MCP 工具名形如 mcp__@@INLINE_0@@__@@INLINE_1@@,服务器名可能暴露用户配置。默认会被替换成通用名。

export function sanitizeToolNameForAnalytics(
  toolName: string,
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
  if (toolName.startsWith('mcp__')) {
    return 'mcp_tool' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
  }
  return toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
}

这段代码证明:可观测性不是越细越好。MCP 服务器名既可能是隐私,也会制造高基数。默认压缩成 mcp_tool,只有 Cowork、官方 claudeai-proxy 或官方注册表服务器等场景才允许更细披露。

文件扩展名也有保护。超过 10 个字符会变成 other,因为“扩展名”可能其实是 hash 或带敏感信息的文件名片段。

第一方投递:批处理、退避、落盘和重传

1P 通道基于 OpenTelemetry。

const eventLoggingExporter = new FirstPartyEventLoggingExporter({
  maxBatchSize: maxExportBatchSize,
  skipAuth: batchConfig.skipAuth,
  maxAttempts: batchConfig.maxAttempts,
  path: batchConfig.path,
  baseUrl: batchConfig.baseUrl,
  isKilled: () => isSinkKilled('firstParty'),
})
firstPartyEventLoggerProvider = new LoggerProvider({
  resource,
  processors: [
    new BatchLogRecordProcessor(eventLoggingExporter, {
      scheduledDelayMillis,
      maxExportBatchSize,
      maxQueueSize,
    }),
  ],
})

这段代码证明:事件不是一条条同步 POST,而是进入 OTel 批处理器。默认会按时间、批次大小或队列容量触发导出。

真正的 CLI 韧性在 FirstPartyEventLoggingExporter

批次分片和短路

for (let i = 0; i < batches.length; i++) {
  const batch = batches[i]!
  try {
    await this.sendBatchWithRetry({ events: batch })
  } catch (error) {
    for (let j = i; j < batches.length; j++) {
      failedBatchEvents.push(...batches[j]!)
    }
    break
  }
  if (i < batches.length - 1 && this.batchDelayMs > 0) {
    await sleep(this.batchDelayMs)
  }
}

这段代码证明:大批量事件会被分片发送,批间有延迟。第一个失败后短路剩余批次,避免端点不可用时继续打网络请求。

二次退避

const delay = Math.min(
  this.baseBackoffDelayMs * this.attempts * this.attempts,
  this.maxBackoffDelayMs,
)

默认从 500ms 开始,按 attempts 平方增长,最多 30 秒。这比固定间隔更适合临时网络抖动,也不会像指数退避那样过快拉长到不可用。

401 降级无认证

if (
  useAuth &&
  axios.isAxiosError(error) &&
  error.response?.status === 401
) {
  const response = await axios.post(this.endpoint, payload, {
    timeout: this.timeout,
    headers: baseHeaders,
  })
  this.logSuccess(payload.events.length, false, response.data)
  return
}

这段代码证明:OAuth token 过期时,遥测不会直接丢弃,而是降级成无认证投递。这样缺少用户身份关联,但仍能保留产品级事件。

失败批次写磁盘

失败事件写入:

~/.claude/telemetry/1p_failed_events.<sessionId>.<batchUUID>.json

Exporter 构造时会后台调用:

void this.retryPreviousBatches()

这证明 Claude Code 承认 CLI 网络不可靠,也承认进程可能退出。失败事件不只在内存里等下一次 retry,而是落盘,下次启动再补送。

实时告警:第三方通道走允许列表

Datadog 通道用于实时告警,不承载完整分析数据。它有策展式允许列表。

const DATADOG_ALLOWED_EVENTS = new Set([
  'chrome_bridge_connection_succeeded',
  'chrome_bridge_connection_failed',
  'tengu_api_error',
  'tengu_api_success',
  'tengu_cancel',
  'tengu_exit',
  'tengu_init',
  'tengu_started',
  'tengu_tool_use_error',
  'tengu_tool_use_success',
  'tengu_uncaught_exception',
  'tengu_unhandled_rejection',
])

这段代码证明:不是所有事件都能去第三方服务。新事件要进入 Datadog,必须显式加入允许列表,这本身就是一个审查点。

用户 ID 不直接上报,而是分桶:

const getUserBucket = memoize((): number => {
  const userId = getOrCreateUserID()
  const hash = createHash('sha256').update(userId).digest('hex')
  return parseInt(hash.slice(0, 8), 16) % NUM_USER_BUCKETS
})

这段代码证明:Datadog 只需要近似唯一用户和分布,不需要真实用户 ID。30 个桶能帮助估算覆盖面,同时降低隐私和高基数风险。

接口与工具事件:把 Agent Loop 拆成可对比阶段

API 调用使用三事件模型:

事件时机关键字段
tengu_api_query请求发出模型、token 预算、缓存配置
tengu_api_success请求成功TTFT、TTLT、总耗时、token 用量
tengu_api_error请求失败错误类型、HTTP 状态码、重试信息

TTFT 是从请求到第一个 token 的时间,TTLT 是到最后一个 token 的时间。二者分开,才能判断慢是模型启动慢、流式生成慢,还是网络/工具链拖慢。

重试通过 tengu_api_retry 单独记录,携带退避时间和状态码。429 和 529 会区分处理,后台请求也会更快放弃,避免影响前台交互。

工具执行事件包括:

事件语义
tengu_tool_use_success工具成功
tengu_tool_use_error工具异常
tengu_tool_use_cancelled用户取消
tengu_tool_use_rejected_in_prompt权限或提示内拒绝

这说明 Claude Code 不只看最终回答,而是把 API、工具、权限和取消拆成不同事件。只有这样才能判断“模型没做”到底是没选工具、权限拒绝、工具失败,还是用户取消。

缓存中断检测:先观察再修复

promptCacheBreakDetection.ts 是可观测性最有 Claude Code 风格的一部分。它在 API 调用前记录 PreviousState,调用后对比缓存命中变化。

PreviousState 包含 systemHashtoolsHashcacheControlHashperToolHashesbetas 等字段。原文注释里还记录了生产数据:77% 的工具缓存中断来自某个工具描述变化,因此加入 per-tool hash 来定位具体工具。

/** Per-tool schema hash. Diffed to name which tool's description changed
 *  when toolSchemasChanged but added=removed=0 (77% of tool breaks per
 *  BQ 2026-03-22). AgentTool/SkillTool embed dynamic agent/command lists. */
perToolHashes: Record<string, number>

这段注释证明:观测字段不是拍脑袋加的,而是被 BigQuery 数据驱动。缓存中断事件不是只说“缓存少了”,而是尽量说清哪个字段变了。

本地诊断三通道

Claude Code 有三种本地日志,PII 策略不同。

通道文件触发方式PII 策略用途
Debug Logutils/debug.ts--debug/debug、环境变量可能包含 PII开发者本地排查
Diagnostic Logutils/diagLogs.tsCLAUDE_CODE_DIAGNOSTICS_FILE严禁 PII容器监控和 session-ingress
Error Logutils/errorLogSink.tsant-only 自动写受控错误信息内部错误回溯

Debug Log 的开启条件很多:

export const isDebugMode = memoize((): boolean => {
  return (
    runtimeDebugEnabled ||
    isEnvTruthy(process.env.DEBUG) ||
    isEnvTruthy(process.env.DEBUG_SDK) ||
    process.argv.includes('--debug') ||
    process.argv.includes('-d') ||
    isDebugToStdErr() ||
    process.argv.some(arg => arg.startsWith('--debug=')) ||
    getDebugFilePath() !== null
  )
})

这段代码证明:调试通道既支持启动时开启,也支持运行时 /debug 开启,还支持模块过滤和文件输出。

Diagnostic Log 的函数名直接带 NoPII

export function logForDiagnosticsNoPII(
  level: DiagnosticLogLevel,
  event: string,
  data?: Record<string, unknown>,
): void

这不是风格问题,而是审查提示。容器诊断日志可能被自动采集,不能混入用户代码或文件路径。

分布式追踪:交互、模型请求、工具执行

OTel tracing 使用三级 span:

claude_code.interaction
  claude_code.llm_request
  claude_code.tool
    claude_code.tool.blocked_on_user
    claude_code.tool.execution
  claude_code.hook

上下文通过 AsyncLocalStorage 传播。

const interactionContext = new AsyncLocalStorage<SpanContext | undefined>()
const toolContext = new AsyncLocalStorage<SpanContext | undefined>()
const activeSpans = new Map<string, WeakRef<SpanContext>>()

这段代码证明:追踪上下文不是手动在线程之间传来传去,而是绑定异步执行链。WeakRef 和 30 分钟 TTL 用来清理孤儿 span,避免 stream 取消、工具异常等路径泄漏活跃 span。

Perfetto 是 ant-only 可视化追踪,输出 Chrome Trace Event。它记录 Agent 层级、API TTFT/TTLT、缓存命中率、工具耗时和用户等待时间。事件数组有 MAX_EVENTS = 100_000 上限,达到后淘汰最旧的一半,但保留元数据事件,保证 UI 仍能显示轨道名称。

优雅关闭:最后 500ms 也要有策略

gracefulShutdown.ts 负责退出路径。退出触发包括 SIGINT、SIGTERM、SIGHUP,以及 macOS 终端关闭时的孤儿进程检测。

if (process.stdin.isTTY) {
  orphanCheckInterval = setInterval(() => {
    if (getIsScrollDraining()) return
    if (!process.stdout.writable || !process.stdin.readable) {
      clearInterval(orphanCheckInterval)
      void gracefulShutdown(129)
    }
  }, 30_000)
  orphanCheckInterval.unref()
}

这段代码证明:Claude Code 不能只依赖信号。macOS 终端关闭可能不发 SIGHUP,只让 TTY 文件描述符失效,所以要轮询 stdin/stdout。

关闭顺序有明确优先级:

sequenceDiagram participant S as Signal participant T as Terminal participant C as Cleanup participant H as SessionEnd Hooks participant A as Analytics participant E as Exit S->>T: 恢复终端模式,同步执行 T->>C: 清理函数,2 秒预算 C->>H: SessionEnd hooks,默认 1.5 秒 H->>A: analytics flush,500ms A->>E: forceExit()

终端恢复最先执行,因为它对用户体验最关键。Analytics flush 只有 500ms,避免网络请求挂住退出。整体失败保险是 max(5s, hookTimeout + 3.5s)

forceExit() 还处理 process.exit() 在 dead terminal 场景抛错的情况,最终回退到 SIGKILL。这说明退出路径也要 fail-closed,不能为了遥测完整性把用户终端卡死。

成本追踪:用量也是观测对象

cost-tracker.ts 维护会话成本快照。

type StoredCostState = {
  totalCostUSD: number
  totalAPIDuration: number
  totalAPIDurationWithoutRetries: number
  totalToolDuration: number
  totalLinesAdded: number
  totalLinesRemoved: number
  lastDuration: number | undefined
  modelUsage: { [modelName: string]: ModelUsage } | undefined
}

这段结构证明:成本不仅是美元。API 总耗时、去掉重试的耗时、工具耗时、增删行数和模型级 token 使用都被记录。恢复会话时只有 session ID 匹配才恢复上次成本,避免不同会话串账。

状态和数据结构

状态或结构关键字段影响
eventQueueQueuedEvent[]sink 未初始化时不丢事件
LogEventMetadata禁止普通 string从类型层面降低 PII 泄漏
_PROTO_* 字段_PROTO_skill_name只进 1P 特权字段,不进 Datadog
DATADOG_ALLOWED_EVENTS允许列表第三方实时通道最小暴露面
FirstPartyEventLoggingExportermaxBatchSizemaxAttemptsisKilled控制批处理、重试和远程熔断
失败事件文件sessionIdbatchUUID跨进程和跨启动重传
PreviousStatesystemHashtoolsHashperToolHashes归因 Prompt Cache 中断
OTel spansinteraction / llm_request / tool把一次用户交互拆成时间线
StoredCostStatetoken、耗时、行数、模型用量成本和效率可视化

设计取舍

第一,入口极轻,后端可晚到。 index.ts 无依赖,先入队再附着 sink,解决启动早期事件和循环导入问题。

第二,隐私用类型和路由共同保护。 普通 metadata 不接受字符串,PII 字段必须 _PROTO_ 标记,Datadog 再剥离一次。

第三,实时和离线分通道。 Datadog 只收允许列表事件用于告警,1P 收更完整数据用于分析,二者的安全级别不同。

第四,CLI 遥测必须能离线。 磁盘持久化和启动重传是本机工具的必要设计,不能照搬服务端内存队列。

第五,退出路径按用户体验排序。 先恢复终端,再清理,再 hooks,再遥测。遥测重要,但不能比用户终端可用性更重要。

读源码抓手

读源码抓手

读可观测性不要从事件名列表开始,而要追一条事件如何从 logEvent() 到达 sink,再看它在失败和退出时怎么办。

建议路线:

  • 先读 services/analytics/index.ts,理解 eventQueueattachAnalyticsSink()
  • 再读 services/analytics/sink.ts,看采样、Datadog 和 1P 分发点。
  • 接着读 metadata.tsfirstPartyEventLoggingExporter.ts,看 PII 字段如何剥离和投递失败如何落盘。
  • 然后读 datadog.ts 的允许列表和 user bucket,理解实时通道为什么要降基数。
  • 再读 services/api/logging.tswithRetry.tstoolExecution.ts,把 Agent Loop 拆成 API 和工具事件。
  • 最后读 sessionTracing.tsperfettoTracing.tsgracefulShutdown.tscost-tracker.ts,看时间线、退出和成本如何补齐。

小结

小结

- Claude Code 的可观测性从 logEvent() 开始,但真正价值在采样、PII、重试、追踪和退出路径。 - never 类型标记和 _PROTO_* 路由把隐私审查前移到编码阶段。 - Datadog 是实时允许列表通道,1P 是完整离线分析通道,二者不能混用。 - Prompt Cache 断点检测体现“先观察再修复”,字段粒度来自生产数据。 - CLI 工具必须假设网络会断、进程会退、终端会坏,所以落盘重试和优雅关闭是遥测的一部分。