Claude Code 的可观测性与版本演进
这一章要解决的问题是:一个运行在用户本机的 CLI Agent,怎么知道自己到底发生了什么?模型为什么变慢、工具为什么失败、权限为什么拒绝、缓存为什么断、进程退出前遥测有没有送出去,这些都不能靠“用户感觉”。
Claude Code 的答案是一套客户端遥测管线。它不是简单 console.log,而是从 logEvent() 入口、sink 附着、采样、PII 过滤、Datadog/1P 双路分发、OpenTelemetry、磁盘重试、调试日志、Perfetto trace、优雅关闭到成本追踪的完整链路。
核心直觉
Agent 的可观测性不是为了事后好看,而是为了把随机决策链变成可对比的工程证据。没有事件、span、成本、缓存和权限记录,就无法判断一次行为变化到底来自模型、提示词、工具、权限、缓存、网络还是版本 flag。
先看源码入口
| 源码定位 | 关键符号 | 负责什么 |
|---|---|---|
services/analytics/index.ts | logEvent()、eventQueue、attachAnalyticsSink() | 全局事件入口,支持 sink 尚未初始化时先排队 |
services/analytics/sink.ts | logEventImpl()、shouldSampleEvent()、stripProtoFields() | 采样、PII 字段剥离、Datadog 和 1P 双路分发 |
services/analytics/metadata.ts | sanitizeToolNameForAnalytics() | MCP 工具名、文件扩展名等敏感维度降级 |
services/analytics/firstPartyEventLogger.ts | BatchLogRecordProcessor | 1P OpenTelemetry logger 管线 |
services/analytics/firstPartyEventLoggingExporter.ts | FirstPartyEventLoggingExporter | 批次分片、退避、401 降级、磁盘持久化和启动重传 |
services/analytics/datadog.ts | DATADOG_ALLOWED_EVENTS、getUserBucket() | 实时告警通道、事件允许列表、用户桶 |
services/analytics/sinkKillswitch.ts | tengu_frond_boric | 远程关闭 Datadog 或 1P sink |
services/api/logging.ts | tengu_api_query、tengu_api_success、tengu_api_error | API 请求三事件模型 |
services/api/withRetry.ts | tengu_api_retry | API 重试原因、退避和状态码遥测 |
services/tools/toolExecution.ts | tengu_tool_use_* | 工具成功、失败、取消、权限拒绝事件 |
services/api/promptCacheBreakDetection.ts | PreviousState、perToolHashes | Prompt Cache 断点检测和原因归因 |
utils/debug.ts、utils/diagLogs.ts、utils/errorLogSink.ts | debug / diagnostic / error logs | 三条本地诊断通道 |
utils/telemetry/sessionTracing.ts | interaction / llm_request / tool spans | OTel 分布式追踪和 AsyncLocalStorage 上下文传播 |
utils/telemetry/perfettoTracing.ts | Chrome Trace Event | ant-only 可视化时间线 |
utils/gracefulShutdown.ts | gracefulShutdown()、forceExit() | 退出时恢复终端、执行 hooks、刷新遥测 |
cost-tracker.ts | StoredCostState、addToTotalSessionCost() | token、成本、工具耗时和代码行变更追踪 |
这一章的主线就是:事件如何进入管线,如何安全地送出去,如何在失败和退出时不丢,以及如何支撑版本演进判断。
总流程图
这张图说明一件事: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 包含 systemHash、toolsHash、cacheControlHash、perToolHashes、betas 等字段。原文注释里还记录了生产数据: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 Log | utils/debug.ts | --debug、/debug、环境变量 | 可能包含 PII | 开发者本地排查 |
| Diagnostic Log | utils/diagLogs.ts | CLAUDE_CODE_DIAGNOSTICS_FILE | 严禁 PII | 容器监控和 session-ingress |
| Error Log | utils/errorLogSink.ts | ant-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。
关闭顺序有明确优先级:
终端恢复最先执行,因为它对用户体验最关键。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 匹配才恢复上次成本,避免不同会话串账。
状态和数据结构
| 状态或结构 | 关键字段 | 影响 |
|---|---|---|
eventQueue | QueuedEvent[] | sink 未初始化时不丢事件 |
LogEventMetadata | 禁止普通 string | 从类型层面降低 PII 泄漏 |
_PROTO_* 字段 | _PROTO_skill_name 等 | 只进 1P 特权字段,不进 Datadog |
DATADOG_ALLOWED_EVENTS | 允许列表 | 第三方实时通道最小暴露面 |
FirstPartyEventLoggingExporter | maxBatchSize、maxAttempts、isKilled | 控制批处理、重试和远程熔断 |
| 失败事件文件 | sessionId、batchUUID | 跨进程和跨启动重传 |
PreviousState | systemHash、toolsHash、perToolHashes | 归因 Prompt Cache 中断 |
| OTel spans | interaction / llm_request / tool | 把一次用户交互拆成时间线 |
StoredCostState | token、耗时、行数、模型用量 | 成本和效率可视化 |
设计取舍
第一,入口极轻,后端可晚到。 index.ts 无依赖,先入队再附着 sink,解决启动早期事件和循环导入问题。
第二,隐私用类型和路由共同保护。 普通 metadata 不接受字符串,PII 字段必须 _PROTO_ 标记,Datadog 再剥离一次。
第三,实时和离线分通道。 Datadog 只收允许列表事件用于告警,1P 收更完整数据用于分析,二者的安全级别不同。
第四,CLI 遥测必须能离线。 磁盘持久化和启动重传是本机工具的必要设计,不能照搬服务端内存队列。
第五,退出路径按用户体验排序。 先恢复终端,再清理,再 hooks,再遥测。遥测重要,但不能比用户终端可用性更重要。
读源码抓手
读源码抓手
读可观测性不要从事件名列表开始,而要追一条事件如何从 logEvent() 到达 sink,再看它在失败和退出时怎么办。
建议路线:
- 先读
services/analytics/index.ts,理解eventQueue和attachAnalyticsSink()。 - 再读
services/analytics/sink.ts,看采样、Datadog 和 1P 分发点。 - 接着读
metadata.ts和firstPartyEventLoggingExporter.ts,看 PII 字段如何剥离和投递失败如何落盘。 - 然后读
datadog.ts的允许列表和 user bucket,理解实时通道为什么要降基数。 - 再读
services/api/logging.ts、withRetry.ts、toolExecution.ts,把 Agent Loop 拆成 API 和工具事件。 - 最后读
sessionTracing.ts、perfettoTracing.ts、gracefulShutdown.ts和cost-tracker.ts,看时间线、退出和成本如何补齐。
小结
小结
- Claude Code 的可观测性从 logEvent() 开始,但真正价值在采样、PII、重试、追踪和退出路径。 - never 类型标记和 _PROTO_* 路由把隐私审查前移到编码阶段。 - Datadog 是实时允许列表通道,1P 是完整离线分析通道,二者不能混用。 - Prompt Cache 断点检测体现“先观察再修复”,字段粒度来自生产数据。 - CLI 工具必须假设网络会断、进程会退、终端会坏,所以落盘重试和优雅关闭是遥测的一部分。