从 Claude Code 源码学习怎么做Agent 可观测性(上)
写在前面
睡了吗?没睡咱们一起看看“泄露”的 cc 源码,本篇从我最感兴趣的话题开始,这也是我做 TMA1 这个项目的原因之一。
翻了一圈主流 agent runtime 的源码,Claude Code 在可观测性架构上下的功夫最深。它对 agent 运行时的语义做了系统性建模,远不止”打日志 + 统计接口耗时”。从当前的源码快照看,有四件事可以拿来用:
-
它把可观测性分成了三层,而不是一锅端。 -
它用统一关联键把 session、agent、parent 串起来。 -
它把 agent runtime 的语义映射成了 span 类型。 -
它让 event、metric、trace 各归各位,不互相越界。
这篇上篇就只讲这四件事。
先说清楚边界
文章里有些分组——比如”产品事件”和”运行时事件”——是我为了讲清观测目标做的分析性划分,不是 Claude Code 源码里的正式类型名。源码里真正可以直接看到的是:
-
services/analytics/*这条 event logging 管线 -
utils/telemetry/*这条 OTel / tracing 管线 -
大量以 tengu_开头的事件名和interaction、llm_request、tool等 span 名
tengu: 天狗(Tengu)是日本民间信仰中的神秘生物,属于神道中的神灵(kami)或妖怪(yōkai),
另一点要注意:tracing 能力不是无条件常开的。sessionTracing.ts 受 enhanced telemetry / beta tracing 条件控制;hook span 只在 beta tracing 打开时创建;Perfetto 是独立的本地 opt-in tracing。所以后面谈”运行时事件”时,核心是在讲 Claude Code 如何观测 agent runtime,不是在说源码里存在一个叫 runtime event 的统一类型系统。
一、不是一层,而是三层
翻 Claude Code 的源码组织,最先注意到的是它把可观测性拆成了三个独立的层次。
第一层:产品分析事件。 入口在 services/analytics/index.ts。这个模块刻意保持无依赖,提供 logEvent() / logEventAsync() 统一入口,sink 尚未挂载时先把事件入队。真正的后端路由在 sink.ts,分流到 Datadog 和 1P event logging。
第二层:标准化 telemetry。 主入口在 utils/telemetry/instrumentation.ts。用 OpenTelemetry 的 metrics / logs / traces,按 exporter 配置导向 OTLP、Prometheus、console 或内部指标导出器。这是”基础设施观测层”,强调标准生态兼容。
第三层:会话级 tracing。 核心模块是 utils/telemetry/sessionTracing.ts。它不追踪网络请求,而是直接对 Claude Code 的业务语义建模——interaction、LLM request、tool 调用、hook、被用户阻塞的时间。把”agent 在做什么”表达成 trace 结构。
三层各管各的事:
-
analytics/*回答”发生了什么产品事件”,产品经理和运管关注的指标。 -
telemetry/instrumentation.ts回答”指标和标准 traces 怎么接入后端” -
sessionTracing.ts回答”一个 agent turn 在 runtime 里是怎么展开的”

为什么不能混在一起?
agent 可观测性不要一上来就追求”全都记下来”,要先做目标分层。否则很快会踩三个坑。
第一,命名体系失控。user_clicked_button 和 model_fallback_triggered 出现在同一个 event stream 里,没人分得清这条 event 该归产品分析还是归工程排查。
第二,后端 schema 和 cardinality 爆炸。我在之前一篇文章里讨论过 agent 可观测数据的特征:半结构化、高维度、高基数。一个典型 agent 执行事件可能有几十上百个字段,不分层管理的话,后端被维度组合淹没只是时间问题。
第三,看到很多”日志”但看不清因果链。一个 user turn 可能拆成多次 LLM request、多个 tool call、多个 subagent。数据平铺在一起,排查时只能靠 grep 和人肉拼凑。
产品事件 vs 运行时事件
在 Claude Code 里,这两类事件的边界不是绝对的,但目标很不同。
产品事件回答”用户做了什么”。源码里的例子:tengu_oauth_flow_start、tengu_plugin_install_command、tengu_input_prompt、tengu_session_resumed。关注的是功能行为、用户旅程和产品指标。或者说,这是产品经理和运营经理关注的事件。
运行时事件回答”一个 turn 怎么跑起来的”。典型的有两类:一类是 tracing span(claude_code.interaction、claude_code.llm_request、claude_code.tool);另一类是虽然走 analytics 管线但语义偏 runtime 的事件(tengu_model_fallback_triggered、tengu_auto_compact_succeeded、tengu_tool_use_can_use_tool_rejected)。这是 SRE/Engineer 视角更关注的事情。
所以更准确的理解不是”两套完全隔离的通道”,而是两种不同的观测目标用三种不同的信号形态来承载。这种”观测目标”和”信号形态”的双维度设计,是 Claude Code 可观测性架构里第一个可以直接借鉴的决策。
工程细节:入口去耦
analytics/index.ts 做了一个很工程化的决定:公共入口做成”无依赖模块 + 启动期队列”。调用方只关心 logEvent(),不知道后端是 Datadog 还是 1P。sink 可以延迟装配,启动早期的事件不会丢,也不引入 import cycle。
1P 指 first-party, CC 内部的事件日志系统。
对大项目来说,这种入口去耦往往比”换了什么 observability 平台”更重要。
二、统一关联键:先把同一个实体串起来
agent 系统里,真正困难的不是”生成一条事件”,而是”让不同事件能被关联起来”。
写 LLM 可观测性那篇实战文章时我反复强调一件事:trace_id 在 Jaeger 里,token 用量在 Prometheus 里,对话内容在 Elasticsearch 里——定位一个问题要跨三个界面来回跳。把三类信号存在同一个库里、用一条 SQL 关联查询,是我认为 LLM 可观测性最该优先解决的问题。Agent 系统比单次 LLM 调用复杂得多,对关联能力的需求只会更强。


在 TMA1 中通过 hooks 拿到所有会话,组织起来的 trace 和 agent 层次架构
Claude Code 在关联键这件事上做得克制。它没有把逻辑散在各个调用点,而是集中在两处:
-
telemetryAttributes.ts:偏 OTel attributes -
metadata.ts:偏 analytics / 1P event metadata
OTel 侧的基础关联键
getTelemetryAttributes() 统一挂一批跨信号共享的基础属性到 metrics / traces 上:user.id、session.id、organization.id、terminal.type 等。
但不是一股脑全开。session.id、版本号、account UUID 这些字段是否带上,受 OTEL_METRICS_INCLUDE_* 环境变量控制。作者在主动管理 cardinality——高基数字段不是默认全塞进指标系统的。
Analytics 侧的核心上下文
metadata.ts 的 getEventMetadata() 补了另一层上下文,不只关心谁发了事件,还关心事件发生在什么运行环境中。源码里可以看到它收集:sessionId、model、userType、betas、entrypoint、clientType、isInteractive、envContext、processMetrics、subscriptionType、repo remote hash。
envContext 和 processMetrics 特别值得注意。Claude Code 把 analytics 当轻量运行时事实采集用,终端类型、平台、CI 状态、CPU/内存等信息跟着事件一起出去,不只是埋点计数。
Agent / parent / team 的关联链
对 agent 系统更关键的一层是”谁是谁的子节点”。
metadata.ts 补了这组字段:agentId、parentSessionId、agentType、teamName。取值的优先级是:AsyncLocalStorage 中的 agent context → swarm / teammate 环境 → bootstrap state 中的 parent session。
为什么这么复杂?同一个系统里,agent 既可能是同进程 subagent,也可能是 swarm teammate,也可能是 standalone agent。可观测性层不能假定只有一种 agent 身份来源,必须自己做统一归并。
归并之后,后端拿到的不是一堆孤立事件,而是一个最小可关联图:当前 session 是谁、当前 agent 是谁、上游 parent session 是谁、它是 teammate / subagent / standalone、属于哪个 team。
排查多代理问题时这很关键。不然你只看到”调了工具”和”发了请求”,不知道动作属于主会话、哪个子代理、还是某个团队 agent。这也是我在讨论 Multi-Agent 可观测性时提到的核心痛点:跨边界的 context 传播。Claude Code 的解法不算万能,但至少在单 runtime 内做到了一致的关联模型。

工程细节:schema 漂移前置到类型层
metadata.ts 在 to1PEventFormat() 里有一段注释:env 使用 proto-generated EnvironmentMetadata 类型,不是手写并行类型。
原因很实际。1P event logging 的目标是字段落进正确的下游 schema。手写平行类型的风险是:新增字段在本地对象里存在,但被生成的 toJSON() 静默丢掉——你以为打点成功,后端根本没收到。event schema 和 proto schema 绑定之后,schema drift 在编译期暴露,不用上线后靠数仓排查。
三、Agent 语义级 span:trace 一个 turn,不是 trace 一个函数
如果只看 OpenTelemetry 接入,很容易以为 tracing 的重点是 exporter 和 provider 怎么配。但 sessionTracing.ts 才是真正有意思的部分。
这个模块的关键在于它把 Claude Code 的业务语义直接编码成了 span 类型:
-
interaction:一次用户交互回合 -
llm_request:一次模型调用 -
tool:一次工具调用 -
tool.blocked_on_user:等待用户批准 -
tool.execution:工具实际执行 -
hook:钩子执行(仅 beta tracing 下创建)
interaction:root span 是”用户的一次交互”
startInteractionSpan() 把从用户输入到 Claude 响应这一整轮当成 root span,创建 claude_code.interaction。根节点的选择很说明问题:可观测性的基本单位是”用户的一次交互回合”,不是某次网络请求。
对 agent 尤其重要。一次 turn 通常包含 prompt 处理、多次模型调用、多个工具执行、可能的人工确认、hook 和恢复逻辑。没有 interaction 作为根节点,后面所有 request / tool span 碎成平铺事件,读 trace 时完全看不出这轮到底发生了什么。
想象在 Grafana 里看到 100 个独立 span,不知道哪些属于同一轮对话。很多 agent 系统做 tracing 就是这个状态。我之前做 LLM 可观测性 demo 时用的是 OTel GenAI 规范的 chat span 作为顶层,但 Claude Code 的 interaction 粒度更合理,因为一次 interaction 可能包含多次 chat completion。
llm_request:显式区分模型调用
startLLMRequestSpan() 创建 claude_code.llm_request,带上模型、速度模式、上下文来源、token、cache、TTFT 等属性。
两个设计点。
第一,它记录 llm_request.context,区分请求是挂在 interaction 下面还是独立的 standalone request。有的模型请求属于一个用户 turn,有的是系统内部附属请求,两者不混淆。
第二,endLLMRequestSpan() 的注释提到并行请求问题:warmup、topic classifier、主线程请求可能同时在飞,结束时必须传回准确的 span 实例,否则响应会被错误归因。这不是纸面 tracing,是在处理 agent runtime 真实的并发归因。
tool 与 tool.execution:把两个阶段拆开
tool span 和 tool.execution span 并存。工具作为 agent 决策的一部分被调用,和工具内部真正进入执行阶段,被拆成了两个 span。
在 toolExecution.ts 中,startToolSpan()、startToolBlockedOnUserSpan() 和 startToolExecutionSpan() 按实际运行顺序串起来。执行链上显式切分了**”被选中”、”等待批准”、”真正执行”**三个阶段,不用事后从日志去猜”大概在等权限”。
这让 trace 能回答更细的问题:工具在等权限时花了多少时间?从被模型选中到开始执行,中间卡在哪?
tool.blocked_on_user:把人在回路的时间独立出来
这个 span 单独拎出来说。很多 agent 平台做 tracing 只追踪模型延迟和工具延迟,但 Claude Code 还单独追踪”等用户批准的时间”。
这等于承认了一个事实:agent 的真实耗时不全是机器耗时。权限审批和人工中断本身就是主链路的一部分。
把这类时间独立出来之后,你才能回答:慢是因为模型慢,还是审批慢?某类工具成功率低,是执行问题还是审批被拒绝太多?一个 turn 很长,时间主要花在 agent 计算上还是等人上?

TMA1 可以看到 AskUserQuestion 事件
我自己用 TMA1[1] 做 session 分析时经常碰到这种情况。一个 session 看起来跑了很长,实际大量时间花在等我点”允许”。trace 里没这个维度的话,延迟分析永远是错的。
AsyncLocalStorage:上下文传播的实用选择
实现上,sessionTracing.ts 用 AsyncLocalStorage 保存 interactionContext 和 toolContext。interaction 是回合根上下文,tool 是工具链局部上下文,后续的 llm_request、tool.execution、hook 自动挂到正确父节点下。
agent 系统里这是很实用的选择。一旦引入 streaming、hook、后台任务、工具嵌套和多代理,靠参数手传 parent trace 很快就控制不住。
工程细节:并发归因和悬挂 span
两个做工程的人会关心的点。
并发归因——endLLMRequestSpan() 明确提醒调用方,多请求并发时必须传回原始 span 实例,否则响应挂到错误请求上。
* IMPORTANT: When multiple LLM requests run in parallel (e.g., warmup requests, * topic classifier, file path extractor, main thread), you MUST pass the specific span * to ensure responses are attached to the correct request. Without it, responses may be * incorrectly attached to whichever span happens to be "last"in the activeSpans map. * * If not provided, falls back to finding the most recent llm_request span (legacy behavior).
悬挂 span 清理——activeSpans 结合 WeakRef、AsyncLocalStorage 和 30 分钟 TTL cleanup,避免 tracing 自身变成长会话的内存负担。agent 执行链长、异常路径多、用户中断频繁,不处理 orphaned span 的话,trace 本身就是内存泄漏源。
源码里 ensureCleanupInterval() 的注释写得很直接:这是 safety net for spans that were never ended。aborted streams、uncaught exceptions、mid-query 中断——这些在 agent runtime 里不是边缘情况,是常态。能不能正确处理它们,是”demo 级 tracing”和”生产级 tracing”的分水岭。
四、Event / Metric / Trace 各归各位
Claude Code 没有把所有信号都压成”事件”,这一点要说一下。
先理清概念:前面的”产品事件 / 运行时事件”是按观测目标分组;这里的 event / metric / trace 是按信号形态分组。前者问”你想观测什么”,后者问”你用什么信号承载”。Claude Code 的做法是把这两个维度分开处理,不搅在一起。
Event:发生了什么
analytics/index.ts、sink.ts、firstPartyEventLogger.ts 负责 event 流,关注某类行为是否发生、发生时的上下文、送往哪个后端。
最重要的设计不是路由,是字段约束。logEvent() 的 metadata 被限制成数值和布尔值;字符串要进来,必须显式通过一个名字极长的标记类型——AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS。这名字看着像玩笑,但意图很认真:在类型层就限制误报敏感信息,不靠事后代码评审。
_PROTO_* 机制也很巧妙:把”允许进入 1P 特权字段的值”和”绝不能进入通用后端的值”分成两条路径。sink.ts 在 Datadog fanout 前统一调 stripProtoFields(),Datadog 永远看不到 _PROTO_* 字段。一个过滤点管住所有 sink,比各写一份脱敏逻辑靠谱。
Trace:怎么串起来的
trace 由 sessionTracing.ts 建模、instrumentation.ts 装配。在 Claude Code 语义层上:interaction 是根,llm_request 和 tool 是子动作,tool.execution/ blocked_on_user / hook 挂更细的上下文。
trace 在这里是因果结构图,不是换了个名字的事件日志。
Metric:整体状态
metric 在 instrumentation.ts 和 bootstrap/state.ts 侧,承载更聚合的状态量——session 数、代码修改量、成本与 token 统计。
三者区别在 agent 系统里尤其明显:event 看离散行为,trace 看因果链,metric 看趋势。不分层的话,要么什么都塞 trace 导致 trace 爆炸,要么什么都打 event 之后只能写 SQL 拼执行过程。
Taxonomy 也是隐私治理
Claude Code 的信号分类带着明显的隐私意识。metadata.ts 里有一整套 MCP 工具名处理逻辑:默认把 mcp__<server>__<tool> 收敛成 mcp_tool,只有官方注册或 claude.ai connector 才放更细的名字出去。
sanitizeToolNameForAnalytics() 在做 PII 边界管理——用户自定义的 MCP server 名可能包含个人信息。
更全局地看,taxonomy 直接约束采集行为:OTEL_METRICS_INCLUDE_* 控制高基数字段、OTEL_LOG_USER_PROMPTS 和 OTEL_LOG_TOOL_CONTENT 控制敏感内容是否进入 trace。在 Claude Code 的设计里,taxonomy 是采集阶段就生效的治理机制,不是事后分类。
可以带走的四个结论
先做目标分层。Claude Code 分出了产品事件、标准 telemetry、会话 tracing 三层。我自己做工具时也发现,”先搞清楚你想看什么”比”先把数据都收起来”重要得多。
统一关联键比多打一条日志重要。sessionId、agentId、parentSessionId、teamName 这套字段,决定了多代理系统的动作能不能串起来。这和我LLM 可观测性实践中的体会一致——三类信号同库、trace_id 关联查询,比三个系统分别记录强太多。
业务语义级 tracing 才真正有用。interaction、llm_request、tool.blocked_on_user 这些 span 比函数耗时更能解释 agent 为什么慢、卡在哪。OTel GenAI 语义规范也在推这个方向——不只定义 gen_ai.request.model,还要把 agent 执行的业务语义标准化。
Taxonomy 是治理问题,不只是分类。对 metadata、字符串、MCP 名称和字段粒度的约束,说明可观测性从一开始就要和隐私、基数、后端能力一起设计。
下篇预告
下篇继续写:可靠性与失败补偿、隐私 / cardinality / feature gate、默认开关矩阵,以及这套设计可以抽取的工程经验与局限。
如果你也对 agent 可观测性感兴趣,可以先翻翻 Claude Code 的 utils/telemetry/ 目录——即使不用 Claude Code 本身,这套 tracing 模型的设计思路也能拿来参考。如果你已经在用 Claude Code 或其他 coding agent,想实际看看这些 session 数据长什么样,可以试试 TMA1[1]——一行命令装上,agent 跑一次就有数据了。
TMA1: https://github.com/tma1-ai/tma1
夜雨聆风