Claude Code 架构全解Claude Code Architecture, in Full第 12 章 · 14 章Chapter 12 of 14
全文目录Contents
  1. 0 · 项目全景与代码地图
    1. 0.1 这个软件是什么
    2. 0.2 源码目录逐个解释
    3. 0.3 从这张地图能读出的三件事
    4. 0.4 一次完整请求的旅程(全文导航)
    5. 0.5 全文章节索引
  2. 1 · 入口层与启动流程
    1. 1.1 四种启动形态
    2. 1.2 启动的第一个设计:快路径分派
    3. 1.3 命令行参数:60 多个选项
    4. 1.4 --bare:一个值得单独讲的极简模式
    5. 1.5 启动时序
    6. 1.6 系统提示词的三段结构
  3. 2 · 会话层:QueryEngine
    1. 2.1 它解决什么问题
    2. 2.2 它持有哪些状态
    3. 2.3 一次 submitMessage 的完整流程
    4. 2.4 用户消息为什么必须先落盘
    5. 2.5 消费主循环输出:一个大 switch
    6. 2.6 压缩分界点:主动释放内存
    7. 2.7 三种退出结果
    8. 2.8 ask():一次性调用的便捷封装
  4. 3 · 智能体主循环 ★
    1. 3.1 循环的骨架
    2. 3.2 State:把跨轮次状态集中管理
    3. 3.3 transition:只为可测试而存在的字段
    4. 3.4 七条转移边逐条详解
    5. 3.5 错误扣留机制
    6. 3.6 中断处理
    7. 3.7 模型降级:三个动作
    8. 3.8 思考块三定律
    9. 3.9 循环里的其他机制
    10. 3.10 循环的所有退出点
  5. 4 · 工具模型
    1. 4.1 Tool 接口:七组正交能力
    2. 4.2 为什么「安全谓词」值得单独成组
    3. 4.3 失败保守默认值
    4. 4.4 40 个内建工具分类
    5. 4.5 渐进式工具加载
    6. 4.6 工具清单装配:一个关于缓存的隐藏约束
    7. 4.7 backfillObservableInput:一个极致的缓存保护例子
  6. 5 · 工具执行
    1. 5.1 执行链路总览
    2. 5.2 并发分区:贪心算法
    3. 5.3 上下文修改要排队到批次结束
    4. 5.4 单次执行:runToolUse 的完整流程
    5. 5.5 流式工具执行器
    6. 5.6 兄弟中止控制器:最漂亮的一处设计
    7. 5.7 丢弃机制
    8. 5.8 「墓碑」消息
    9. 5.9 结果的最终处理
  7. 6 · 上下文治理 ★
    1. 6.1 五级流水线
    2. 6.2 第 ① 级:工具结果预算
    3. 6.3 第 ③ 级:微压缩与缓存编辑
    4. 6.4 第 ⑤ 级:自动摘要压缩
    5. 6.5 上下文真的超了:三级恢复瀑布
  8. 7 · 权限系统
    1. 7.1 六种权限模式
    2. 7.2 十步决策级联
    3. 7.3 自动模式:模型分类器 + 三级快速通道
    4. 7.4 权限规则的语法
    5. 7.5 沙箱与只读命令判定
    6. 7.6 权限判定的完整数据结构
    7. 7.7 权限决策的可解释性
  9. 8 · 子智能体
    1. 8.1 首要动机是上下文隔离,不是并行
    2. 8.2 三种形态
    3. 8.3 分叉:把提示词缓存用到极致
    4. 8.4 子智能体的工具限制
    5. 8.5 子智能体的上下文构造
    6. 8.6 内建智能体类型
    7. 8.7 后台任务的几种形态
    8. 8.8 中断的级联
  10. 9 · 扩展体系
    1. 9.1 四类扩展点对照
    2. 9.2 技能系统
    3. 9.3 插件系统
    4. 9.4 MCP 客户端
    5. 9.5 钩子:15 类生命周期事件
    6. 9.6 输出样式
  11. 10 · 终端界面层
    1. 10.1 用 React 写终端界面
    2. 10.2 最大的四个组件
    3. 10.3 输入框为什么有 347 KB
    4. 10.4 虚拟消息列表
    5. 10.5 工具结果的六种渲染状态
    6. 10.6 折叠:避免刷屏
    7. 10.7 87 个状态管理单元
    8. 10.8 界面和内核的接口:ToolUseContext 里的回调
    9. 10.9 一个有趣的细节:ANSI 转 PNG
  12. 11 · 持久化与恢复
    1. 11.1 对话记录的格式:JSONL
    2. 11.2 对话记录是一棵树,不是一个列表
    3. 11.3 写入队列
    4. 11.4 子智能体的记录:支链文件
    5. 11.5 恢复:三种方式
    6. 11.6 文件历史:智能体改过的文件可以回滚
    7. 11.7 配置的多来源与迁移
    8. 11.8 记忆目录
  13. 12 · 可观测体系
    1. 12.1 埋点密度
    2. 12.2 事件命名
    3. 12.3 查询链路追踪
    4. 12.4 类型层面的隐私保护
    5. 12.5 缓存断裂检测
    6. 12.6 性能剖析检查点
    7. 12.7 慢操作日志
    8. 12.8 内存错误缓冲区
    9. 12.9 内部错误的响亮日志
    10. 12.10 埋点的成本意识
  14. 13 · 构建与分发
    1. 13.1 Bun 单文件可执行程序
    2. 13.2 编译期特性开关:89 个
    3. 13.3 死代码消除:为什么这不只是「if 判断」
    4. 13.4 编译期宏
    5. 13.5 运行时特性开关:另一套系统
    6. 13.6 版本与更新
    7. 13.7 从构建方式反推的架构约束
  1. 0 · The Project at a Glance, and a Map of the Code
    1. 0.1 What this software is
    2. 0.2 The source tree, directory by directory
    3. 0.3 Three things you can read off this map
    4. 0.4 The journey of one complete request (a map of the whole book)
    5. 0.5 Chapter index
  2. 1 · The Entry Layer and Startup
    1. 1.1 Four launch modes
    2. 1.2 The first design decision at startup: fast-path dispatch
    3. 1.3 Command-line arguments: 60-plus options
    4. 1.4 --bare: a minimal mode worth its own section
    5. 1.5 Startup sequence
    6. 1.6 The three-part structure of the system prompt
  3. 2 · The Session Layer: QueryEngine
    1. 2.1 The problem it solves
    2. 2.2 What state it holds
    3. 2.3 The full flow of one submitMessage
    4. 2.4 Why the user message must hit disk first
    5. 2.5 Consuming the main loop’s output: one big switch
    6. 2.6 The compact boundary: proactively releasing memory
    7. 2.7 Three kinds of exit result
    8. 2.8 ask(): a convenience wrapper for one-shot calls
  4. 3 · The Agent Main Loop ★
    1. 3.1 The skeleton of the loop
    2. 3.2 State: centralizing cross-iteration state
    3. 3.3 transition: a field that exists purely for testability
    4. 3.4 The seven transition edges, one by one
    5. 3.5 The error-withholding mechanism
    6. 3.6 Interrupt handling
    7. 3.7 Model fallback: three actions
    8. 3.8 The three laws of thinking blocks
    9. 3.9 Other mechanisms in the loop
    10. 3.10 Every exit point of the loop
  5. 4 · The Tool Model
    1. 4.1 The Tool interface: seven orthogonal capability groups
    2. 4.2 Why the “safety predicates” deserve their own group
    3. 4.3 Fail-safe defaults
    4. 4.4 The 40 built-in tools, by category
    5. 4.5 Progressive tool loading
    6. 4.6 Tool list assembly: a hidden constraint about caching
    7. 4.7 backfillObservableInput: an extreme example of cache protection
  6. 5 · Tool Execution
    1. 5.1 The execution pipeline at a glance
    2. 5.2 Concurrency partitioning: a greedy algorithm
    3. 5.3 Context modifications are queued until the batch ends
    4. 5.4 A single execution: the full flow of runToolUse
    5. 5.5 The streaming tool executor
    6. 5.6 The sibling abort controller: the most elegant design in the file
    7. 5.7 The discard mechanism
    8. 5.8 “Tombstone” messages
    9. 5.9 Final processing of results
  7. 6 · Context Management ★
    1. 6.1 The five-tier pipeline
    2. 6.2 Tier ①: the tool result budget
    3. 6.3 Tier ③: micro-compaction and cache edits
    4. 6.4 Tier ⑤: auto summary compaction
    5. 6.5 When the context really does overflow: the three-tier recovery cascade
  8. 7 · The Permission System
    1. 7.1 Six Permission Modes
    2. 7.2 The Ten-Step Decision Cascade
    3. 7.3 Auto Mode: Model Classifier + Three-Tier Fast Path
    4. 7.4 Permission Rule Syntax
    5. 7.5 Sandboxing and Read-Only Command Detection
    6. 7.6 The Full Data Structure Behind a Permission Decision
    7. 7.7 Explainability of Permission Decisions
  9. 8 · Subagents
    1. 8.1 The Primary Motivation Is Context Isolation, Not Parallelism
    2. 8.2 Three Forms
    3. 8.3 Forking: Pushing Prompt Caching to the Limit
    4. 8.4 Tool Restrictions on Subagents
    5. 8.5 Constructing a Subagent's Context
    6. 8.6 Built-in Agent Types
    7. 8.7 The Forms Background Tasks Take
    8. 8.8 Cascading Interruption
  10. 9 · The Extension System
    1. 9.1 The Four Extension Points Compared
    2. 9.2 The Skill System
    3. 9.3 The Plugin System
    4. 9.4 The MCP Client
    5. 9.5 Hooks: 15 Kinds of Lifecycle Events
    6. 9.6 Output Styles
  11. 10 · The Terminal UI Layer
    1. 10.1 Writing a Terminal UI in React
    2. 10.2 The Four Biggest Components
    3. 10.3 Why the Input Box Is 347 KB
    4. 10.4 The Virtualized Message List
    5. 10.5 Six Rendering States for Tool Results
    6. 10.6 Collapsing: Avoiding Screen Flood
    7. 10.7 87 State-Management Units
    8. 10.8 The Interface Between UI and Kernel: Callbacks in ToolUseContext
    9. 10.9 A Fun Detail: ANSI to PNG
  12. 11 · Persistence and Resume
    1. 11.1 The Transcript Format: JSONL
    2. 11.2 The Transcript Is a Tree, Not a List
    3. 11.3 The Write Queue
    4. 11.4 Subagent Records: Sidechain Files
    5. 11.5 Resume: Three Ways
    6. 11.6 File History: Rolling Back Files the Agent Changed
    7. 11.7 Multi-Source Settings and Migrations
    8. 11.8 The Memory Directory
  13. 12 · The Observability System
    1. 12.1 Instrumentation Density
    2. 12.2 Event Naming
    3. 12.3 Query Chain Tracing
    4. 12.4 Privacy Protection at the Type Level
    5. 12.5 Cache-Break Detection
    6. 12.6 Profiling Checkpoints
    7. 12.7 Slow-Operation Logging
    8. 12.8 The In-Memory Error Buffer
    9. 12.9 Loud Logging for Internal Errors
    10. 12.10 Cost-Awareness in Instrumentation
  14. 13 · Build and Distribution
    1. 13.1 The Bun Single-File Executable
    2. 13.2 Compile-Time Feature Flags: 89 of Them
    3. 13.3 Dead-Code Elimination: Why It's Not Just an “if”
    4. 13.4 Compile-Time Macros
    5. 13.5 Runtime Feature Flags: A Separate System
    6. 13.6 Versions and Updates
    7. 13.7 Architectural Constraints Inferred from the Build

12 · 可观测体系

这一章讲一个在架构讨论里几乎从不出现、但决定了产品能不能长期演进的东西:系统怎么知道自己在发生什么。

12.1 埋点密度

先看数字:

指标数值
去重的事件名数量660 个
埋点调用点数量1,093 处
平均每个源文件约 0.57 处埋点
相对于主循环query.ts 1,730 行里有 十几处埋点,几乎每条决策分支都有

660 个不同的事件名意味着什么?

意味着这个系统里几乎每一个「值得区分的情况」都有自己的名字。不是「工具调用成功/失败」这种粗粒度,而是「延迟加载的工具因为说明未发送而参数校验失败」这种细粒度。

这直接决定了排查问题的能力:当用户报告「智能体有时候会卡住」,你可以直接查数据 —— 是哪条恢复路径被触发了?触发频率是多少?哪个模型版本更高发?而不是只能靠复现。

12.2 事件命名

所有事件都以 tengu_ 开头(tengu 是内部代号)。从主循环里出现的事件名可以看出命名规律:

事件名记录什么
tengu_auto_compact_succeeded自动压缩成功,带压缩前后 token 数、压缩本身的花费
tengu_post_autocompact_turn压缩之后的每一轮(带 turnId 和轮次计数)
tengu_cached_microcompact缓存微压缩执行,带删了几个、剩几个、阈值配置
tengu_time_based_microcompact时间触发的微压缩,带间隔分钟数、清了几条、省了多少 token
tengu_model_fallback_triggered模型降级,带原模型和备用模型
tengu_orphaned_messages_tombstoned孤儿消息被打墓碑,带数量
tengu_max_tokens_escalate输出上限升档,带升到多少
tengu_streaming_tool_execution_used
tengu_streaming_tool_execution_not_used
成对的事件,记录流式执行器有没有被启用,带工具数量
tengu_query_before_attachments
tengu_query_after_attachments
成对的事件,记录附件处理前后的消息数量
tengu_token_budget_completedtoken 预算用完,带是否是「收益递减」提前停止
tengu_query_error查询出错,带已产生的消息数、工具调用数
tengu_auto_mode_decision自动模式的每一个决策,带走的是哪条快速通道
tengu_tool_use_error工具调用出错,带错误类型和详情
tengu_deferred_tool_schema_not_sent延迟加载的工具被调用但说明还没发送

命名的两个规律

规律一:成对埋点。xxx_used / xxx_not_usedxxx_before / xxx_after —— 这样才能算出比率和差值,而不只是绝对数。

比如 tengu_streaming_tool_execution_used/not_used 这一对,能直接算出「流式执行器的启用率」。如果某次发布后这个比率突然掉了,说明有代码路径意外地绕过了它。

规律二:带上决策的「为什么」。tengu_auto_mode_decision 不只记录「允许还是拒绝」,还记录 fastPath: 'acceptEdits' | 'allowlist' —— 走的是哪条快速通道。这样才能评估「快速通道挡掉了百分之多少的分类器调用」,也就是这个优化到底值不值。

12.3 查询链路追踪

几乎所有埋点都带两个字段:

queryChainId: queryChainIdForAnalytics,   // 这次用户请求的唯一 ID
queryDepth:   queryTracking.depth,        // 智能体嵌套深度(主 0,子 1,孙 2)

生成逻辑在第 3.9 节讲过:

const queryTracking = toolUseContext.queryTracking
  ? { chainId: toolUseContext.queryTracking.chainId,          // 继承父的链路 ID
      depth:   toolUseContext.queryTracking.depth + 1 }       // 深度 +1
  : { chainId: deps.uuid(), depth: 0 }                        // 顶层,新建

有了这两个字段,就能把「一次用户请求引发的所有模型调用」串成一棵树。

能回答的问题包括:
· 一次典型请求平均派生多少个子智能体?
· 子智能体的失败率是不是显著高于主智能体?
· 深度 2 的调用(孙子级)实际发生频率有多高?值不值得支持?
· 某次超长请求的成本,具体花在哪一层?

没有链路 ID,这些问题全都答不了 —— 你只能看到一堆孤立的模型调用记录。

12.4 类型层面的隐私保护

埋点代码里到处是一个奇怪的类型转换:

toolName: sanitizeToolNameForAnalytics(tool.name),
errorDetails: errorContent.slice(0, 2000)
  as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryChainId: queryTracking.chainId
  as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,

这个类型的名字直译是:分析元数据_我已确认这不是代码或文件路径

这是一个很聪明的工程手段

问题:埋点数据要上报到服务器。而用户的代码和文件路径绝对不能上报 —— 那是隐私和商业机密。

但埋点字段是自由文本,编译器无法自动判断「这个字符串里有没有用户代码」。

解法:让类型系统强制开发者显式声明。埋点函数的参数类型是这个特殊类型,任何字符串都必须显式 as 转换过去。而这个转换的名字长得让人无法忽视 —— 你在写下 I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 的时候,不可能没意识到自己在声明什么。

这比写一份「埋点规范文档」有效得多。文档没人看,而这个类型名你每次埋点都必须敲一遍。而且在代码评审时,这一行会非常显眼。

配套还有清洗函数:sanitizeToolNameForAnalytics() —— 因为 MCP 工具的名字包含服务名,而服务名可能是用户自定义的、含敏感信息的。

12.5 缓存断裂检测

有一个专门的子系统监控提示词缓存的健康度:services/api/promptCacheBreakDetection.ts,由编译期开关 PROMPT_CACHE_BREAK_DETECTION 控制。

它监控什么

正常情况下,一场会话的缓存读取量应该是逐轮递增的(历史越来越长,命中的缓存越来越多)。如果某一轮突然暴跌,说明缓存被打断了 —— 有代码改动了上下文前缀。

但有些下跌是合法的

所以系统提供了主动通知的接口:

// microCompact.ts,缓存编辑执行后
// Notify cache break detection that cache reads will legitimately drop
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
  notifyCacheDeletion(querySource ?? 'repl_main_thread')
}

// 时间触发的微压缩执行后
// We just changed the prompt content — the next response's cache read will
// be low, but that's us, not a break. Tell the detector to expect a drop.
if (feature('PROMPT_CACHE_BREAK_DETECTION') && querySource) {
  notifyCacheDeletion(querySource)
}

译:我们刚刚改了提示词内容 —— 下一次响应的缓存读取量会很低,但这是我们自己造成的,不是断裂。告诉检测器预期会有一次下跌。

这个设计的价值

缓存命中率是这个系统最重要的成本指标(回顾第 4.6 节:系统提示词的缓存是跨用户共享的,一次排序 bug 能让所有人的缓存全崩)。

但如果监控只会报「缓存掉了」,那么每次正常的压缩都会误报,告警很快就会被忽略。

所以必须区分「预期内的下跌」和「意外的断裂」 —— 做法是让所有会主动改动上下文的代码路径,显式通知检测器。这样剩下的告警才是真信号。

12.6 性能剖析检查点

代码里散布着两套检查点:

// 启动路径
profileCheckpoint('cli_entry')
profileCheckpoint('cli_dump_system_prompt_path')
profileCheckpoint('cli_bridge_path')
...

// 无头模式的延迟追踪
headlessProfilerCheckpoint('before_getSystemPrompt')
headlessProfilerCheckpoint('after_getSystemPrompt')
headlessProfilerCheckpoint('before_skills_plugins')
headlessProfilerCheckpoint('after_skills_plugins')
headlessProfilerCheckpoint('system_message_yielded')
headlessProfilerCheckpoint('query_started')

// 主循环内部
queryCheckpoint('query_fn_entry')
queryCheckpoint('query_snip_start') / queryCheckpoint('query_snip_end')
queryCheckpoint('query_microcompact_start') / ('query_microcompact_end')
queryCheckpoint('query_autocompact_start') / ('query_autocompact_end')
queryCheckpoint('query_setup_start') / ('query_setup_end')
queryCheckpoint('query_api_loop_start')
queryCheckpoint('query_api_streaming_start') / ('query_api_streaming_end')
queryCheckpoint('query_tool_execution_start') / ('query_tool_execution_end')
queryCheckpoint('query_recursive_call')

注意这些检查点的分布:几乎完全对应第 6 章那条五级流水线的每一级。这不是巧合 —— 只有把每一级的耗时单独测出来,才能判断「这一级的优化是不是值得」。

还有一个更重的方案:编译期开关 PERFETTO_TRACING。Perfetto 是 Google 的性能追踪工具,能生成可视化的时间线。

12.7 慢操作日志

编译期开关里有 SLOW_OPERATION_LOGGING,对应的还有一个模块 utils/slowOperations.ts。里面导出了一个函数叫 jsonStringify —— 也就是说,连「把对象转成 JSON 字符串」这个操作都被单独包装并纳入了慢操作监控。

为什么?因为在这个系统里,被序列化的对象可能是几百 MB 的消息历史JSON.stringify 在这个规模下会阻塞主线程几百毫秒 —— 而主线程同时在渲染流式界面,卡顿会立刻被用户看到。

12.8 内存错误缓冲区

const errorLogWatermark = getInMemoryErrors().at(-1)
...
errors: (() => {
  const all = getInMemoryErrors()
  const start = errorLogWatermark ? all.lastIndexOf(errorLogWatermark) + 1 : 0
  return [ `[ede_diagnostic] ...`, ...all.slice(start).map(_ => _.error) ]
})()

系统在内存里维护一个只保留最近 100 条的环形缓冲区存放错误日志。当一次执行失败时,把「本轮范围内」的错误一起打包进结果。

第 2.7 节讲过这里的水位标记技巧:记住元素的引用而不是数组下标,因为环形缓冲区会移位,下标会滑走。

12.9 内部错误的响亮日志

// To help track down bugs, log loudly for ants
logAntError('Query error', error)

系统区分了两种错误日志:

函数行为
logError(error)常规记录。所有用户都走这条
logAntError(msg, error)只对内部用户「响亮地」报错 —— 可能是在界面上直接显示、或者上报到内部告警系统

这是「用自己的产品」(内部试用)的工程化体现。

外部用户遇到一个内部 bug 时,你不想用一大堆技术细节吓到他们 —— 应该优雅降级。
内部用户遇到同一个 bug 时,你希望它尽可能刺眼 —— 因为他们是唯一有能力立刻反馈和修复的人。

同一份代码,两种错误响度。这需要一个「用户类型」的概念贯穿全系统(process.env.USER_TYPE === 'ant'),而这个判断在源码里出现了几十次。

12.10 埋点的成本意识

最后值得注意的一点:埋点本身也有成本,而 Claude Code 对此有意识。

// query.ts
const dumpPromptsFetch = config.gates.isAnt
  ? createDumpPromptsFetch(toolUseContext.agentId ?? config.sessionId)
  : undefined

注释解释了为什么这个对象只创建一次:

「Create fetch wrapper once per query session to avoid memory retention. Each call to createDumpPromptsFetch creates a closure that captures the request body. Creating it once means only the latest request body is retained (~700KB), instead of all request bodies from the session (~500MB for long sessions).」

译:每个查询会话只创建一次这个包装器,以避免内存滞留。每次调用都会创建一个捕获了请求体的闭包。只创建一次意味着只保留最新的那个请求体(约 700 KB),而不是整场会话的所有请求体(长会话下约 500 MB)。

一个用于调试的功能,如果实现不当,会让长会话多占 500 MB 内存。这就是为什么可观测性的实现本身也需要被仔细设计。

12 · The Observability System

This chapter covers something that almost never appears in architecture discussions but decides whether a product can keep evolving over the long run: how the system knows what's happening to itself.

12.1 Instrumentation Density

The numbers first:

MetricValue
Distinct event names660
Instrumentation call sites1,093
Per source file, on averageAbout 0.57 call sites
Relative to the main loopquery.ts has a dozen-plus call sites in 1,730 lines; nearly every decision branch has one

What do 660 distinct event names mean?

They mean that nearly every “situation worth distinguishing” in this system has its own name. Not coarse-grained like “tool call succeeded/failed,” but fine-grained like “a deferred tool's parameter validation failed because its schema hadn't been sent.”

This directly determines your ability to troubleshoot: when a user reports “the agent sometimes gets stuck,” you can go straight to the data — which recovery path fired? How often? Which model version sees it most? Rather than relying on reproduction alone.

12.2 Event Naming

Every event starts with tengu_ (tengu is an internal codename). The event names that appear in the main loop show the naming pattern:

Event nameWhat it records
tengu_auto_compact_succeededAuto-compaction succeeded, with token counts before and after and the cost of the compaction itself
tengu_post_autocompact_turnEvery turn after a compaction (with turnId and turn count)
tengu_cached_microcompactCached microcompaction ran, with how many were removed, how many remain, and the threshold config
tengu_time_based_microcompactTime-triggered microcompaction, with the interval in minutes, how many were cleared, and how many tokens were saved
tengu_model_fallback_triggeredModel fallback, with the original and fallback models
tengu_orphaned_messages_tombstonedOrphaned messages tombstoned, with the count
tengu_max_tokens_escalateOutput cap escalated, with the new cap
tengu_streaming_tool_execution_used
tengu_streaming_tool_execution_not_used
A paired event recording whether the streaming executor was enabled, with the tool count
tengu_query_before_attachments
tengu_query_after_attachments
A paired event recording the message count before and after attachment processing
tengu_token_budget_completedToken budget exhausted, with whether it was a “diminishing returns” early stop
tengu_query_errorQuery error, with the number of messages and tool calls produced so far
tengu_auto_mode_decisionEvery auto-mode decision, with which fast path it took
tengu_tool_use_errorTool call error, with error type and details
tengu_deferred_tool_schema_not_sentA deferred tool was called but its schema hadn't been sent yet

Two patterns in the naming

Pattern one: paired instrumentation. xxx_used / xxx_not_used, xxx_before / xxx_after — that's what lets you compute ratios and deltas, not just absolute counts.

The pair tengu_streaming_tool_execution_used/not_used, for instance, gives you “the streaming executor's enablement rate” directly. If that ratio suddenly drops after a release, some code path is unexpectedly bypassing it.

Pattern two: carry the decision's “why.” tengu_auto_mode_decision records not just “allowed or denied” but also fastPath: 'acceptEdits' | 'allowlist' — which fast path was taken. That's what lets you evaluate “what percentage of classifier calls did the fast paths intercept,” in other words, whether the optimization is worth it at all.

12.3 Query Chain Tracing

Nearly every instrumentation call carries two fields:

queryChainId: queryChainIdForAnalytics,   // unique ID for this user request
queryDepth:   queryTracking.depth,        // agent nesting depth (main 0, child 1, grandchild 2)

The generation logic was covered in section 3.9:

const queryTracking = toolUseContext.queryTracking
  ? { chainId: toolUseContext.queryTracking.chainId,          // inherit the parent's chain ID
      depth:   toolUseContext.queryTracking.depth + 1 }       // depth +1
  : { chainId: deps.uuid(), depth: 0 }                        // top level: create a new one

With these two fields, “every model call triggered by one user request” can be strung into a tree.

Questions it can answer include:
· How many subagents does a typical request spawn on average?
· Is the subagent failure rate significantly higher than the main agent's?
· How often do depth-2 (grandchild) calls actually happen? Are they worth supporting?
· For one extremely long request, which level did the cost actually go to?

Without a chain ID, none of these questions can be answered — all you see is a pile of isolated model-call records.

12.4 Privacy Protection at the Type Level

The instrumentation code is full of an odd type cast:

toolName: sanitizeToolNameForAnalytics(tool.name),
errorDetails: errorContent.slice(0, 2000)
  as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
queryChainId: queryTracking.chainId
  as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,

Read the type name as plain words: analytics metadata: I verified this is not code or file paths.

A very clever engineering move

The problem: instrumentation data gets reported to a server. And the user's code and file paths must never be reported — that's privacy and trade secrets.

But instrumentation fields are free text, and the compiler can't automatically tell “whether this string contains user code.”

The solution: make the type system force the developer to declare it explicitly. The instrumentation function's parameter type is this special type, and any string has to be explicitly cast to it with as. And the cast's name is too long to ignore — when you type out I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, you can't fail to realize what you're declaring.

That's far more effective than writing an “instrumentation guidelines” document. Nobody reads documents, but you have to type this type name every time you add an event. And in code review, the line stands out.

There's a companion sanitizer too: sanitizeToolNameForAnalytics() — because MCP tool names include the server name, and server names can be user-defined and contain sensitive information.

12.5 Cache-Break Detection

A dedicated subsystem monitors the health of the prompt cache: services/api/promptCacheBreakDetection.ts, controlled by the compile-time flag PROMPT_CACHE_BREAK_DETECTION.

What it monitors

Normally, a session's cache-read volume should grow turn over turn (the history keeps getting longer, so more and more of it hits the cache). If it suddenly plunges on some turn, the cache was broken — some code changed the context prefix.

But some drops are legitimate

So the system provides an interface for proactive notification:

// microCompact.ts, after a cache edit runs
// Notify cache break detection that cache reads will legitimately drop
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
  notifyCacheDeletion(querySource ?? 'repl_main_thread')
}

// after time-based microcompaction runs
// We just changed the prompt content — the next response's cache read will
// be low, but that's us, not a break. Tell the detector to expect a drop.
if (feature('PROMPT_CACHE_BREAK_DETECTION') && querySource) {
  notifyCacheDeletion(querySource)
}

Put plainly: we just changed the prompt content — the next response's cache read will be low, but that's us, not a break. Tell the detector to expect a drop.

Why this design matters

Cache hit rate is this system's most important cost metric (recall section 4.6: the system prompt's cache is shared across users, and one ordering bug can wipe out everyone's cache).

But if the monitor could only report “the cache dropped,” every normal compaction would be a false alarm, and the alert would soon be ignored.

So it has to distinguish “expected drops” from “unexpected breaks” — by having every code path that deliberately changes the context notify the detector explicitly. Only then are the remaining alerts real signal.

12.6 Profiling Checkpoints

Two sets of checkpoints are scattered through the code:

// startup path
profileCheckpoint('cli_entry')
profileCheckpoint('cli_dump_system_prompt_path')
profileCheckpoint('cli_bridge_path')
...

// latency tracking in headless mode
headlessProfilerCheckpoint('before_getSystemPrompt')
headlessProfilerCheckpoint('after_getSystemPrompt')
headlessProfilerCheckpoint('before_skills_plugins')
headlessProfilerCheckpoint('after_skills_plugins')
headlessProfilerCheckpoint('system_message_yielded')
headlessProfilerCheckpoint('query_started')

// inside the main loop
queryCheckpoint('query_fn_entry')
queryCheckpoint('query_snip_start') / queryCheckpoint('query_snip_end')
queryCheckpoint('query_microcompact_start') / ('query_microcompact_end')
queryCheckpoint('query_autocompact_start') / ('query_autocompact_end')
queryCheckpoint('query_setup_start') / ('query_setup_end')
queryCheckpoint('query_api_loop_start')
queryCheckpoint('query_api_streaming_start') / ('query_api_streaming_end')
queryCheckpoint('query_tool_execution_start') / ('query_tool_execution_end')
queryCheckpoint('query_recursive_call')

Note how these checkpoints are distributed: they map almost exactly onto each stage of the five-stage pipeline from chapter 6. That's no coincidence — only by measuring each stage's time on its own can you judge “whether optimizing this stage is worth it.”

There's a heavier option too: the compile-time flag PERFETTO_TRACING. Perfetto is Google's performance tracing tool, which produces a visual timeline.

12.7 Slow-Operation Logging

The compile-time flags include SLOW_OPERATION_LOGGING, with a matching module utils/slowOperations.ts. It exports a function called jsonStringify — meaning even “turn an object into a JSON string” has been wrapped on its own and brought under slow-operation monitoring.

Why? Because in this system, the object being serialized may be hundreds of MB of message history. At that scale, JSON.stringify blocks the main thread for hundreds of milliseconds — and the main thread is simultaneously rendering the streaming UI, so the stutter is visible to the user immediately.

12.8 The In-Memory Error Buffer

const errorLogWatermark = getInMemoryErrors().at(-1)
...
errors: (() => {
  const all = getInMemoryErrors()
  const start = errorLogWatermark ? all.lastIndexOf(errorLogWatermark) + 1 : 0
  return [ `[ede_diagnostic] ...`, ...all.slice(start).map(_ => _.error) ]
})()

The system keeps a ring buffer holding only the most recent 100 entries of error logs in memory. When an execution fails, the errors “within this turn's range” get packaged into the result together.

Section 2.7 covered the watermark trick here: remember a reference to the element, not an array index, because the ring buffer shifts and indices slide away.

12.9 Loud Logging for Internal Errors

// To help track down bugs, log loudly for ants
logAntError('Query error', error)

The system distinguishes two kinds of error logging:

FunctionBehavior
logError(error)Regular logging. Every user goes through this
logAntError(msg, error)Reports the error “loudly,” for internal users only — possibly displayed directly in the UI, or sent to an internal alerting system

This is “dogfooding” (internal trial use) made concrete in engineering.

When an external user hits an internal bug, you don't want to scare them with a pile of technical detail — degrade gracefully.
But when an internal user hits the same bug, you want it as glaring as possible — because they're the only ones able to report and fix it right away.

Same code, two error volumes. That requires a “user type” concept running through the whole system (process.env.USER_TYPE === 'ant'), and that check appears dozens of times in the source.

12.10 Cost-Awareness in Instrumentation

One last point worth noting: instrumentation has costs of its own, and Claude Code is aware of it.

// query.ts
const dumpPromptsFetch = config.gates.isAnt
  ? createDumpPromptsFetch(toolUseContext.agentId ?? config.sessionId)
  : undefined

The comment explains why this object is created only once:

“Create fetch wrapper once per query session to avoid memory retention. Each call to createDumpPromptsFetch creates a closure that captures the request body. Creating it once means only the latest request body is retained (~700KB), instead of all request bodies from the session (~500MB for long sessions).”

Put plainly: create this wrapper once per query session to avoid memory retention. Every call creates a closure that captures the request body. Creating it once means only the latest request body is retained (about 700 KB), instead of every request body from the whole session (about 500 MB for long sessions).

A debugging feature, implemented poorly, can cost a long session an extra 500 MB of memory. That's why the implementation of observability itself needs careful design.