Claude Code 源码分析第 32 课 · 第 05
第 32 课

分析与遥测

Claude Code 如何跟踪使用情况、将事件路由到 Datadog 和第一方日志记录、使用 GrowthBook 控制功能以及在数据离开计算机之前对其进行清理。

01 架构概述

Claude Code 的分析系统分为六个文件 services/analytics/。 设计分开 what 登录自 how 事件被路由,保持公共 API 不依赖,以便任何模块都可以调用 logEvent() 无需在导入时引入 Datadog 或 OTel 传输。

index.ts

公共API + 队列

零依赖。 暴露 logEvent / logEventAsync。 将事件排队直到连接接收器。

sink.ts

Router

启动时初始化。 将事件扇出至 Datadog 和 1P、应用采样、检查功能门、剥离 PII。

datadog.ts

数据狗传输

批量记录日志,每 15 秒或累积 100 个条目时刷新。 仅限第一方用户。

metadata.ts

元数据丰富

将平台、模型、会话、代理 ID、流程指标和存储库哈希附加到每个事件。

growthbook.ts

功能标志

GrowthBook 远程评估客户端。 驱动 A/B 实验、配置值和终止开关。

sinkKillswitch.ts

紧急关闭开关

成长簿配置 tengu_frond_boric 可以禁用单个接收器而不需要释放。

02 预接收队列

由于分析导入发生在应用程序完全初始化之前,因此启动期间触发的事件将会丢失。 index.ts 使用模块级数组解决这个问题:

// index.ts — simplified
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)
}

export function attachAnalyticsSink(newSink: AnalyticsSink): void {
  if (sink !== null) return  // idempotent
  sink = newSink

  if (eventQueue.length > 0) {
    const queuedEvents = [...eventQueue]
    eventQueue.length = 0
    queueMicrotask(() => {
      for (const event of queuedEvents) { sink!.logEvent(event.eventName, event.metadata) }
    })
  }
}
设计洞察
队列通过以下方式排出 queueMicrotask 而不是同步。 这避免了增加启动热路径的延迟——启动代码调用 attachAnalyticsSink 并返回; 积压工作将在下一个微任务检查点耗尽。
幂等性守卫
Both attachAnalyticsSink and initializeAnalyticsSink (在sink.ts中)是显式幂等的。 他们可以从 preAction hooks(子命令)和 from setup() (默认命令)无需协调或双重连接。

03 接收器路由——sink.ts

sink.ts 是交通警察。 对于每个事件,它在调度之前都会应用三个顺序检查:

flowchart TD A[logEvent called] --> B{shouldSampleEvent?} B -- sample_rate=0 --> DROP[Drop event] B -- sampled --> C{isSinkKilled datadog?} C -- yes --> E[Skip Datadog] C -- no --> D{shouldTrackDatadog?} D -- no --> E D -- yes --> F[stripProtoFields] F --> G[trackDatadogEvent] E --> H[logEventTo1P with full payload] G --> H style DROP fill:#c47a50,color:#141211 style G fill:#22201d,color:#b8b0a4 style H fill:#b8965e,color:#141211
// sink.ts — logEventImpl
function logEventImpl(eventName: string, metadata: LogEventMetadata): void {
  const sampleResult = shouldSampleEvent(eventName)
  if (sampleResult === 0) return  // dropped by sampling

  const metadataWithSampleRate =
    sampleResult !== null
      ? { ...metadata, sample_rate: sampleResult }
      : metadata

  if (shouldTrackDatadog()) {
    // strip _PROTO_* keys — PII tagged, Datadog is general-access
    void trackDatadogEvent(eventName, stripProtoFields(metadataWithSampleRate))
  }
  // 1P exporter receives the full payload including _PROTO_* keys
  logEventTo1P(eventName, metadataWithSampleRate)
}
PII 分离
带前缀的键 _PROTO_ 通过 1P 导出器路由到特权 BigQuery 列。 stripProtoFields() 在 Datadog 扇出之前调用,因此这些值永远不会到达通用访问后端。 然后,出口商将它们提升到原型油田后,防御性地剥离它们,因此无法识别 _PROTO_foo 将来不能默默地登陆 BQ JSON blob。

04 数据狗传输

Datadog 收到一份精心策划的事件允许列表。 活动不在 DATADOG_ALLOWED_EVENTS 都被默默丢弃。 这使得 Datadog 专注于运营监控而不是产品分析。

tengu_init tengu_started tengu_api_success tengu_api_error tengu_tool_use_success tengu_tool_use_error tengu_exit tengu_oauth_success tengu_oauth_error tengu_cancel tengu_uncaught_exception tengu_compact_failed chrome_bridge_* tengu_team_mem_*
// datadog.ts — batching + flush
const DEFAULT_FLUSH_INTERVAL_MS = 15000
const MAX_BATCH_SIZE = 100
const NETWORK_TIMEOUT_MS = 5000

function scheduleFlush(): void {
  if (flushTimer) return
  flushTimer = setTimeout(() => {
    flushTimer = null
    void flushLogs()
  }, getFlushIntervalMs()).unref()  // .unref() — never blocks process exit
}

// Flush immediately if batch is full, otherwise schedule
if (logBatch.length >= MAX_BATCH_SIZE) {
  void flushLogs()
} else {
  scheduleFlush()
}

基数减少

在构建 Datadog 标签之前,一些标准化可以减少度量基数:

型号名称

  • 对于非 Anthropic 用户,模型名称将规范化为其基本名称。
  • 未知型号被列为 "other" 以避免标签爆炸。
  • Ant(内部用户)保留完整的模型字符串以进行调试。

MCP 工具名称

  • mcp__slack__post_message becomes "mcp" 在 Datadog 标签中。
  • 根据内部分类,用户配置的服务器名称是 PII 中型。
  • 在metadata.ts 中允许通过的官方注册服务器。

版本字符串

  • 开发构建: 2.0.53-dev.20251124.t173302.sha526cc6a2.0.53-dev.20251124
  • 时间戳和 SHA 被剥离以避免每次构建都有新标签。

用户桶

  • 用户 ID 散列到 30 个存储桶中的 1 个(SHA-256,前 8 个十六进制字符 mod 30)。
  • 启用近似唯一用户计数,无需记录实际 ID。
  • 保护隐私,同时支持对“受影响的用户”发出警报。

05 元数据丰富——metadata.ts

每场活动都充满了丰富的内容 EventMetadata 组装的对象 getEventMetadata()。 这包括三个嵌套层:

EnvContext — 平台和运行时快照

每个进程构建一次并记住。 捕获:

EnvContext {
  platform: 'darwin' | 'linux' | 'windows'
  platformRaw:          // raw process.platform (freebsd visible here, not bucketed)
  arch: 'arm64' | 'x64'
  nodeVersion:          // runtime version string
  terminal:             // $TERM_PROGRAM
  packageManagers:      // comma-joined: 'npm,bun'
  runtimes:             // node, bun, deno detection
  isCi:                 // $CI env var
  isGithubAction:       // $GITHUB_ACTIONS
  isClaudeAiAuth:       // OAuth vs API key auth
  version:              // full semver build string
  wslVersion:           // WSL 1/2 if applicable
  vcs:                   // 'git' | 'hg' | ... detected VCS
  // + GitHub Actions runner metadata, container IDs, remote session IDs
}
ProcessMetrics — 每个事件的内存和 CPU 增量
ProcessMetrics {
  uptime:           // process.uptime()
  rss:              // resident set size in bytes
  heapTotal:        // V8 heap allocated
  heapUsed:         // V8 heap in use
  cpuPercent:       // delta since last event (user+sys µs / wall-clock ms)
  constrainedMemory: // process.constrainedMemory() if available
}

CPU 百分比是 delta: (userDeltaµs + sysDeltaµs) / (wallDeltaMs × 1000) × 100。 模块级变量 prevCpuUsage and prevWallTimeMs 在通话之间坚持。

代理识别——群体和子代理归因
EventMetadata {
  agentId?:         // CLAUDE_CODE_AGENT_ID or subagent UUID
  parentSessionId?:  // lead session for cross-session joining in BQ
  agentType?:        // 'teammate' | 'subagent' | 'standalone'
  teamName?:         // swarm team label
  rh?:               // first 16 chars of SHA-256(repo remote URL)
  subscriptionType?: // 'max' | 'pro' | 'enterprise' | 'team'
  kairosActive?:     // ant-only KAIROS assistant flag
}

首先检查 AsyncLocalStorage(对于在同一进程中运行的子代理),然后检查环境变量(对于在单独进程中运行的 swarm 队友)。 这意味着归因是自动的——呼叫者不需要手动传递代理上下文。

06 metadata.ts 中的 PII 清理

类型系统本身强制要求在记录之前检查字符串。 事件元数据中的任何字符串值都必须转换为两种标记类型之一 - 两者都键入为 never,因此它们只能用作记录开发人员意图的转换:

// You cannot store a value in these types — they're "never".
// They exist solely as cast targets to document a review decision.
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED = never

// Usage example — sanitizing MCP tool names:
export function sanitizeToolNameForAnalytics(toolName: string) {
  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
}

工具输入截断

When OTEL_LOG_TOOL_DETAILS=1 设置后,工具输入将针对 OTel 范围进行序列化。 超过 512 个字符的字符串将被截断为 128 个字符,并带有长度注释。 对象限制为深度 2 和 20 个项目。 总 JSON 的上限为 4 KB。

文件扩展名提取

对于 bash 工具调用,Claude Code 从已知命令中提取文件扩展名(cat, mv, cp, rm等)来跟踪正在操作的文件类型 - 而不记录实际的文件路径。 超过 10 个字符的扩展名将被编辑为 "other" 以防止基于哈希的文件名泄漏。

07 功能标志 — GrowthBook

GrowthBook 驱动器具有标志、A/B 实验、动态配置和终止开关。 Claude Code 使用其 远程评估 模式:服务器评估该用户的所有规则并返回预先计算的值,而不是将完整的规则树发送到客户端。

用于定位的用户属性

GrowthBookUserAttributes {
  id:               // stable device UUID
  sessionId:        // per-session ID
  deviceID:         // same as id
  platform:         // 'win32' | 'darwin' | 'linux'
  organizationUUID?: // enterprise org targeting
  accountUUID?:     // account-level targeting
  subscriptionType?: // 'max' | 'pro' | 'enterprise' | 'team'
  email?:           // ant-only: always included for internal targeting
  appVersion?:      // semver for version-range gates
  github?:          // GitHub Actions runner metadata
}

三级优先级

优先级1

Env 被覆盖

CLAUDE_INTERNAL_FC_OVERRIDES JSON 对象。 仅限蚂蚁。 由评估线束用于确定性标志状态。

优先级2

配置覆盖

设置通过 /config Gates 运行时选项卡。 存储在 ~/.claude.json。 仅限蚂蚁。

优先级3

远程评估

取自 api.anthropic.com。 每次成功提取时都会缓存到磁盘,以便下一个会话以最后的已知值开始。

SDK 解决方法 — 远程评估格式

GrowthBook API 返回特征值 { "value": ... } 形状,但 SDK 期望 { "defaultValue": ... }。 Claude Code 通过在加载之前转换有效负载并维护其自己的负载来解决此问题 remoteEvalFeatureValues 绕过 SDK 本地重新评估的映射:

// growthbook.ts — processRemoteEvalPayload (simplified)
for (const [key, feature] of Object.entries(payload.features)) {
  if ('value' in feature && !('defaultValue' in feature)) {
    transformedFeatures[key] = { ...feature, defaultValue: feature.value }
  }
  // Cache evaluated value directly to avoid SDK re-evaluation
  remoteEvalFeatureValues.set(key, feature.value ?? feature.defaultValue)
}
安全卫士
如果服务器返回空 features 对象(瞬时错误或被截断的响应), processRemoteEvalPayload returns false 无需清除磁盘缓存。 这可以防止所有进程共享的完全标志中断 ~/.claude.json.

实验曝光重复数据删除

当 GrowthBook 将用户分配给实验变体时,Claude Code 会记录一个 曝光事件 到 1P 后端进行正确的实验分析。 模块级 loggedExposures 设置确保每个功能在每个会话中最多触发一次曝光事件 - 防止调用的热代码路径中出现重复条目 getFeatureValue 在每个渲染上。

08 终止开关

如果分析中的错误导致出现问题(流量过大、意外 PII 等),Anthropic 可以远程禁用各个接收器,而无需发布新版本:

// sinkKillswitch.ts
// Deliberately obfuscated config key to avoid easy discovery
const SINK_KILLSWITCH_CONFIG_NAME = 'tengu_frond_boric'

export function isSinkKilled(sink: 'datadog' | 'firstParty'): boolean {
  const config = getDynamicConfig_CACHED_MAY_BE_STALE<
    Partial<Record<'datadog' | 'firstParty', boolean>>
  >('tengu_frond_boric', {})
  return config?.[sink] === true
}
故障开放设计
如果终止开关配置丢失、格式错误或 GrowthBook 尚未加载, isSinkKilled returns false — 接收器保持启用状态。 这可以防止 GrowthBook 中断也导致分析沉默。 仅当配置显式设置时才会禁用接收器 { "datadog": true }.

09 当分析被禁用时

config.ts 定义了完全抑制分析的条件:

export function isAnalyticsDisabled(): boolean {
  return (
    process.env.NODE_ENV === 'test'               // test environments
    || isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)  // AWS Bedrock
    || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)   // GCP Vertex
    || isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)  // Azure Foundry
    || isTelemetryDisabled()                          // user privacy setting
  )
}

第三方云提供商(Bedrock、Vertex、Foundry)完全禁用分析 - 如果没有 Anthropic 身份验证上下文,事件数据将毫无意义,并且这些用户拥有自己的日志记录管道。 这 isTelemetryDisabled() 路径尊重用户的隐私级别设置。

反馈调查不同
isFeedbackSurveyDisabled() does not 检查第三方提供商。 调查提示是本地 UI,没有文字记录数据,因此它们在基岩/顶点上保持活动状态,而分析将被阻止。

10 事件采样

高频事件(例如 tengu_api_success 在繁忙的会话中)可以产生巨大的音量。 GrowthBook配置键 tengu_event_sampling_config 让 Anthropic 设置每个事件的采样率,无需更改代码:

// firstPartyEventLogger.ts — shouldSampleEvent
export function shouldSampleEvent(eventName: string): number | null {
  const config = getEventSamplingConfig()   // from GrowthBook cache
  const eventConfig = config[eventName]

  if (!eventConfig) return null             // no config = 100% logging

  const sampleRate = eventConfig.sample_rate
  if (sampleRate <= 0)  return 0            // 0 = drop everything
  if (sampleRate >= 1)  return null         // 1 = keep everything (no annotation needed)

  // Probabilistic: roll the dice
  return Math.random() < sampleRate
    ? sampleRate    // sampled — return rate so it can be added to event metadata
    : 0            // not sampled — sink drops it
}

当对事件进行采样时, sample_rate 附加到其元数据中。 这使得分析师可以应用逆概率加权来重建未采样的总数。

11 文件参考

核心文件

  • services/analytics/index.ts
  • services/analytics/sink.ts
  • services/analytics/config.ts
  • services/analytics/sinkKillswitch.ts

后端和丰富

  • services/analytics/datadog.ts
  • services/analytics/metadata.ts
  • services/analytics/growthbook.ts
  • services/analytics/firstPartyEventLogger.ts

12 要点

Decoupling

零深度公共 API

任何模块都可以调用 logEvent() 无需创建导入周期。 接收器稍后在启动过程中接线。

Privacy

类型强制清理

字符串需要显式转换为标记类型。 如果代码中没有开发人员签名注释,任何字符串都无法到达后端。

Control

GrowthBook 作为控制平面

采样率、特征门、A/B 实验和紧急终止开关都流经单个远程评估端点。

Resilience

随处失效打开

缺少配置、失败的获取和 GrowthBook 中断都默认保持分析运行 - 永远不会意外地静默数据。

Attribution

从第一天起就具有群体意识

代理类型、团队名称和父会话 ID 通过 AsyncLocalStorage 自动附加到每个事件 — 无需手动传播。

Operations

优雅关机

shutdownDatadog() 之前被调用 process.exit() 刷新内存中的批处理,以便会话的最后事件永远不会丢失。