成本分析
Claude Code 如何跟踪代币支出、通过双分析管道路由事件、应用 PII 控件以及控制 GrowthBook 背后的一切(从单个 API 响应到 BigQuery)。
Claude Code 运行 两个完全独立的可观察性堆栈 并联。 一个是内部第一方 (1P) 管道,由 OpenTelemetry 和发送到的原始序列化事件支持 /api/event_logging/batch。 另一个是用于运行监控的 Datadog HTTP 接收扇出。 一个薄薄的 sink 层位于中间,决定哪个管道接收每个事件 - 以及是否发送它。
queueMicrotask 启动时耗尽),因此在应用程序初始化期间不会丢失任何内容。 1P 管道和 Datadog 管道是完全独立的——终止开关可以使一个管道静音而不影响另一个管道。
每个 API 响应都带有一个 BetaUsage object. addToTotalSessionCost() 是风扇将数据同时发送到三个目的地的单一入口点:内存中累加器、OpenTelemetry 计数器和分析事件日志。
addToTotalSessionCost — 完整实施
// cost-tracker.ts
export function addToTotalSessionCost(
cost: number,
usage: Usage,
model: string,
): number {
// 1. Accumulate per-model token counters in memory
const modelUsage = addToTotalModelUsage(cost, usage, model)
addToTotalCostState(cost, modelUsage, model)
// 2. Push to OTel counters (for BigQuery / customer OTLP)
const attrs = isFastModeEnabled() && usage.speed === 'fast'
? { model, speed: 'fast' }
: { model }
getCostCounter()?.add(cost, attrs)
getTokenCounter()?.add(usage.input_tokens, { ...attrs, type: 'input' })
getTokenCounter()?.add(usage.output_tokens, { ...attrs, type: 'output' })
getTokenCounter()?.add(usage.cache_read_input_tokens ?? 0,
{ ...attrs, type: 'cacheRead' })
getTokenCounter()?.add(usage.cache_creation_input_tokens ?? 0,
{ ...attrs, type: 'cacheCreation' })
// 3. Log advisor sub-usage to 1P analytics
let totalCost = cost
for (const advisorUsage of getAdvisorUsage(usage)) {
const advisorCost = calculateUSDCost(advisorUsage.model, advisorUsage)
logEvent('tengu_advisor_tool_token_usage', {
advisor_model: advisorUsage.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
input_tokens: advisorUsage.input_tokens,
cost_usd_micros: Math.round(advisorCost * 1_000_000),
})
totalCost += addToTotalSessionCost(advisorCost, advisorUsage, advisorUsage.model)
}
return totalCost
}
成本存储在 microdollars 当作为分析元数据发送时(cost_usd_micros) 将值保留为整数。 原始浮点数用于 OTel 计数器。 这可以防止事件负载中出现浮点累积错误。
会话保持
当会话结束(或暂停)时 saveCurrentSessionCosts() 将完整快照写入项目配置 JSON。 简历上, restoreCostStateForSession() 从该快照中重新水化内存中的计数器 - 但仅当 sessionId 在配置中匹配当前会话。
会话保存/恢复模式
// Save on process exit via useCostSummary React hook
export function saveCurrentSessionCosts(fpsMetrics?: FpsMetrics): void {
saveCurrentProjectConfig(current => ({
...current,
lastCost: getTotalCostUSD(),
lastAPIDuration: getTotalAPIDuration(),
lastAPIDurationWithoutRetries: getTotalAPIDurationWithoutRetries(),
lastTotalInputTokens: getTotalInputTokens(),
lastTotalOutputTokens: getTotalOutputTokens(),
lastTotalCacheCreationInputTokens: getTotalCacheCreationInputTokens(),
lastTotalCacheReadInputTokens: getTotalCacheReadInputTokens(),
lastTotalWebSearchRequests: getTotalWebSearchRequests(),
lastFpsAverage: fpsMetrics?.averageFps,
lastModelUsage: Object.fromEntries(
Object.entries(getModelUsage()).map(([model, u]) => [
model,
{ inputTokens: u.inputTokens, outputTokens: u.outputTokens,
cacheReadInputTokens: u.cacheReadInputTokens,
cacheCreationInputTokens: u.cacheCreationInputTokens,
costUSD: u.costUSD },
])
),
lastSessionId: getSessionId(),
}))
}
// Restore only when session IDs match
export function restoreCostStateForSession(sessionId: string): boolean {
const data = getStoredSessionCosts(sessionId)
if (!data) return false
setCostStateForRestore(data)
return true
}
costHook.ts 看似很小(22 行),但它将成本节省连接到 React 生命周期中,以便 CLI 的退出处理程序始终捕获累积的成本状态,无论进程如何终止。
// costHook.ts — full file
import { useEffect } from 'react'
import { formatTotalCost, saveCurrentSessionCosts } from './cost-tracker.js'
import { hasConsoleBillingAccess } from './utils/billing.js'
import type { FpsMetrics } from './utils/fpsTracker.js'
export function useCostSummary(
getFpsMetrics?: () => FpsMetrics | undefined,
): void {
useEffect(() => {
const f = () => {
// Only print the summary if the user has console billing access
if (hasConsoleBillingAccess()) {
process.stdout.write('\n' + formatTotalCost() + '\n')
}
saveCurrentSessionCosts(getFpsMetrics?.())
}
process.on('exit', f)
return () => { process.off('exit', f) }
}, [])
}
process.on('exit') 里面的处理程序 useEffect 将处理程序生命周期与 React 组件树联系起来 - 如果根组件卸载,则处理程序将被删除,从而防止在许多会话顺序运行的测试环境中出现内存泄漏。
分析模块(services/analytics/index.ts)故意有 零依赖 以防止导入循环。 它仅公开三个功能: logEvent, logEventAsync, 和 attachAnalyticsSink。 其他所有内容——Datadog、1P、GrowthBook——都位于该文件之外。
Queue-then-drain 启动模式(完整实现)
// services/analytics/index.ts
const eventQueue: QueuedEvent[] = []
let sink: AnalyticsSink | null = null
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) {
event.async
? void sink!.logEventAsync(event.eventName, event.metadata)
: sink!.logEvent(event.eventName, event.metadata)
}
})
}
}
export function logEvent(eventName: string, metadata: LogEventMetadata): void {
if (sink === null) {
eventQueue.push({ eventName, metadata, async: false })
return
}
sink.logEvent(eventName, metadata)
}
接收器实现(services/analytics/sink.ts)通过采样门路由每个事件,然后扇出到 Datadog(剥离后 _PROTO_* 键)和 1P 管道(具有完整的有效负载)。
// services/analytics/sink.ts — core routing
function logEventImpl(eventName: string, metadata: LogEventMetadata): void {
const sampleResult = shouldSampleEvent(eventName)
if (sampleResult === 0) return // dropped
const metadataWithSampleRate = sampleResult !== null
? { ...metadata, sample_rate: sampleResult }
: metadata
if (shouldTrackDatadog()) {
// Strip _PROTO_* keys — Datadog is general-access, PII must stay out
void trackDatadogEvent(eventName, stripProtoFields(metadataWithSampleRate))
}
// 1P receives full payload; exporter handles PII routing internally
logEventTo1P(eventName, metadataWithSampleRate)
}
Datadog 收到一份包含约 40 个事件的精选许可名单。 活动不在 DATADOG_ALLOWED_EVENTS 都被默默地丢弃了。 此白名单的存在是为了保持 Datadog 索引较小并防止大量内部事件的意外泄漏。
带 15 秒计时器的 HTTP 批处理
事件累积于 logBatch[]。 每 15 秒或当批次达到 100 个条目时刷新一次,以先到者为准。
模型和工具名称规范化
外部用户模型名称映射到规范短名称或“其他”。 MCP 工具名称折叠为“mcp”以防止每个工具基数爆炸。
SHA-256 → 30 个桶
用户 ID 被散列并存储到 30 个槽中的 1 个槽中。 仪表板对唯一存储桶进行计数以近似唯一用户,而无需跟踪个人 ID。
仅限第一方 API
Bedrock、Vertex 和 Foundry 提供商的 Datadog 事件被跳过 — getAPIProvider() !== 'firstParty' 是一个硬退出。
可查询 Dim 的 ddtags
高基数维度(型号、版本、平台)落地 ddtags 因此可以通过 Datadog 聚合 API 查询它们。 这 message 字段是保留字段,不可查询。
退出时优雅冲洗
shutdownDatadog() 取消刷新计时器并在之前发布任何剩余批次 process.exit().
用户存储桶实现——隐私保护基数
const NUM_USER_BUCKETS = 30
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
})
哈希值截断为 8 个十六进制字符(32 位),以保持在 JavaScript 安全整数范围内。 模确定性地映射到 0-29。 这使得警报仪表板在唯一用户计数上的误差范围约为 3.3%,同时使个人重新识别变得不切实际。
1P管道建立在 OpenTelemetry 日志 SDK。 一个专门的 LoggerProvider - 与面向客户的遥测提供商不同 - 推动 BatchLogRecordProcessor 缓冲事件并将它们批量导出到 Anthropic 的内部端点。
(OTel Logger) participant Batch as BatchLogRecordProcessor participant Exporter as FirstPartyEventLoggingExporter participant Disk as Failed Events
(JSONL on disk) participant API as /api/event_logging/batch App->>Logger: emit({ body: eventName, attributes }) Logger->>Batch: onEmit(log record) Note over Batch: Buffer until scheduledDelayMillis
or maxExportBatchSize (200) Batch->>Exporter: export(logs[]) Exporter->>Exporter: transformLogsToEvents()
hoist _PROTO_* keys Exporter->>API: POST /api/event_logging/batch alt success API-->>Exporter: 200 OK Exporter->>Disk: delete failed events file else failure API-->>Exporter: 5xx / timeout Exporter->>Disk: appendEventsToFile()
JSONL append (atomic) Exporter->>Exporter: scheduleBackoffRetry()
quadratic: base * attempts² end
弹性:磁盘支持的重试
当 POST 失败时,失败的事件将附加到每个会话的 JSONL 文件中,位置为 ~/.claude/telemetry/1p_failed_events.<sessionId>.<BATCH_UUID>.json。 下次启动时, retryPreviousBatches() 扫描同一会话中的任何剩余文件并重试。 这意味着事件可以在进程崩溃和临时网络中断的情况下幸存下来。
二次退避调度器
private scheduleBackoffRetry(): void {
if (this.cancelBackoff || this.isRetrying || this.isShutdown) return
// Quadratic backoff: base * attempts² (matches Statsig SDK)
const delay = Math.min(
this.baseBackoffDelayMs * this.attempts * this.attempts,
this.maxBackoffDelayMs, // caps at 30 000 ms
)
this.cancelBackoff = this.schedule(async () => {
this.cancelBackoff = null
await this.retryFailedEvents()
}, delay)
}
尝试 1:500 毫秒延迟。 尝试 2:2 000 毫秒。 尝试 3:4 500 毫秒。 尝试 4:8 000 毫秒。 尝试 5:12 500 毫秒。 从尝试 8 开始,上限为 30 000 毫秒。 在该批次被永久丢弃之前,总共最多尝试 8 次。
OTel LoggerProvider 分离
1P LoggerProvider is 从未在全球注册过 via logs.setGlobalLoggerProvider()。 面向客户的 OTLP 遥测使用全球提供商。 内部事件日志记录使用模块本地提供程序,因此客户 OTLP 端点永远不会看到内部 Anthropic 事件,反之亦然。
// firstPartyEventLogger.ts — two providers, never mixed
// The 1P provider is kept module-local:
let firstPartyEventLoggerProvider: LoggerProvider | null = null
let firstPartyEventLogger: Logger | null = null
// Customer telemetry uses the global provider in instrumentation.ts:
// logs.setGlobalLoggerProvider(loggerProvider) ← customer endpoint
// setEventLogger(logs.getLogger(...)) ← customer event logger
//
// 1P provider is obtained from the local instance, NOT from logs.getLogger():
firstPartyEventLogger = firstPartyEventLoggerProvider.getLogger(
'com.anthropic.claude_code.events',
MACRO.VERSION,
)
Claude Code 使用 TypeScript 幻像类型系统在编译时强制实施 PII 卫生,并使用运行时键命名约定将敏感值仅路由到特权存储。
编译时防护:从不键入的标记类型
// services/analytics/index.ts
// Type is `never` — cannot hold a value, only used for casting.
// Any string passed to logEvent() must be explicitly cast to this type,
// forcing developers to verify it contains no code/file paths.
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never
// Second marker for PII-tagged values destined for privileged BQ columns.
// These values travel with the event but are stripped before Datadog.
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED = never
never 是 TypeScript 中的底层类型,无法合法地为其赋值。 将字符串传递为 AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 需要明确的 as 投掷。 该转换向代码审阅者发出了一个信号:“作者有意识地确认该字符串可以安全记录。” 类型系统无法验证声明,但它强制执行可见的意图声明。
运行时防护:_PROTO_* 键约定
属于 PII 但在特权 BigQuery 列中可接受的值(例如技能名称、插件名称)将添加到前缀为键下的事件负载中 _PROTO_。 水槽呼叫 stripProtoFields() 在Datadog扇出之前。 已知的 1P 出口商葫芦 _PROTO_ 键到专用原型字段,然后调用 stripProtoFields() 再次作为防守包罗万象。
// services/analytics/index.ts
export function stripProtoFields<V>(
metadata: Record<string, V>,
): Record<string, V> {
let result: Record<string, V> | undefined
for (const key in metadata) {
if (key.startsWith('_PROTO_')) {
if (result === undefined) result = { ...metadata }
delete result[key]
}
}
return result ?? metadata // same reference when no _PROTO_ keys present
}
// In the 1P exporter — hoist then strip:
const { _PROTO_skill_name, _PROTO_plugin_name, _PROTO_marketplace_name, ...rest } = formatted.additional
const additionalMetadata = stripProtoFields(rest) // catches future unknown _PROTO_* keys
events.push({
event_type: 'ClaudeCodeInternalEvent',
event_data: ClaudeCodeInternalEvent.toJSON({
skill_name: typeof _PROTO_skill_name === 'string' ? _PROTO_skill_name : undefined,
additional_metadata: Buffer.from(jsonStringify(additionalMetadata)).toString('base64'),
}),
})
GrowthBook 在分析堆栈中具有三个不同的用途:代码行为的功能标志、管道调整的动态配置以及实验分配跟踪。
tengu_log_datadog_events
启用或禁用每个用户/组织/推出的整个 Datadog 管道。 期间检查过一次 initializeAnalyticsGates() 并为会话缓存。
tengu_event_sampling_config
JSON 配置将事件名称映射到 { sample_rate: 0–1 }。 事件不在 100% 的配置日志中。 速率 0 = 全部丢弃。 用途 getDynamicConfig_CACHED_MAY_BE_STALE.
tengu_1p_event_batch_config
控制 OTel BatchLogRecordProcessor: scheduledDelayMillis, maxExportBatchSize, maxQueueSize, 端点, skipAuth。 可实时重新加载通过 reinitialize1PEventLoggingIfConfigChanged().
tengu_frond_boric
每个接收器分析终止开关的损坏名称。 形状: { datadog?: boolean, firstParty?: boolean }。 价值 true 立即停止所有发送到该接收器的操作。
shouldSampleEvent — GrowthBook 驱动的采样实现
// firstPartyEventLogger.ts
export function shouldSampleEvent(eventName: string): number | null {
const config = getEventSamplingConfig() // GrowthBook dynamic config
const eventConfig = config[eventName]
if (!eventConfig) return null // not configured → log 100%
const sampleRate = eventConfig.sample_rate
if (typeof sampleRate !== 'number' || sampleRate < 0 || sampleRate > 1)
return null // invalid → fail-open (log 100%)
if (sampleRate >= 1) return null // rate=1 → log 100%, no metadata overhead
if (sampleRate <= 0) return 0 // rate=0 → drop
return Math.random() < sampleRate ? sampleRate : 0
}
// Caller in sink.ts:
// null → event passes through, no sample_rate metadata added
// 0 → event dropped
// 0–1 → event passes, sample_rate added to metadata for downstream correction
实时管道重新初始化
长时间运行的会话会自动获取批量配置更改。 当GrowthBook刷新时, reinitialize1PEventLoggingIfConfigChanged() 将新配置与 lastBatchConfig。 如果不同,它将清空记录器(因此并发发出看不到记录器并干净地保释),将旧缓冲区强制刷新到磁盘,交换新的提供程序,然后在后台关闭旧的提供程序。
企业可以通过设置选择在自己的 OTLP 端点接收遥测数据 CLAUDE_CODE_ENABLE_TELEMETRY=1 and OTEL_EXPORTER_OTLP_ENDPOINT。 该路径使用 global OTel 提供商,完全独立于 1P 分析管道。
| Signal | 协议选项 | Notes |
|---|---|---|
| Metrics | otlp/grpc、otlp/http+json、otlp/http+proto、普罗米修斯、控制台 | 默认临时性:delta。 BigQuery 读取器的间隔为 60 秒,可供客户配置。 |
| Logs | otlp/grpc、otlp/http+json、otlp/http+proto、控制台 | 默认导出间隔5s。 除非 OTEL_LOG_USER_PROMPTS=1,否则用户提示已被编辑。 |
| Traces | otlp/grpc、otlp/http+json、otlp/http+proto、控制台 | 需要 CLAUDE_CODE_ENABLE_TELEMETRY + isEnhancedTelemetryEnabled()。 批处理跨度处理器。 |
BigQuery 指标导出器
API 客户、C4E(Claude for Enterprise)和 Teams 用户还可以获得 BigQueryMetricsExporter 发布到 /api/claude_code/metrics 每 5 分钟一次。 这与客户 OTLP 是分开的 — 无论是否运行,它都会运行 CLAUDE_CODE_ENABLE_TELEMETRY 已设置。
客户 OTLP 的遥测属性 — 基数控制
// utils/telemetryAttributes.ts
const METRICS_CARDINALITY_DEFAULTS = {
OTEL_METRICS_INCLUDE_SESSION_ID: true, // included by default
OTEL_METRICS_INCLUDE_VERSION: false, // excluded by default
OTEL_METRICS_INCLUDE_ACCOUNT_UUID: true, // included by default
}
export function getTelemetryAttributes(): Attributes {
const attributes: Attributes = { 'user.id': getOrCreateUserID() }
if (shouldIncludeAttribute('OTEL_METRICS_INCLUDE_SESSION_ID'))
attributes['session.id'] = getSessionId()
if (shouldIncludeAttribute('OTEL_METRICS_INCLUDE_VERSION'))
attributes['app.version'] = MACRO.VERSION
// OAuth account data only when actively using OAuth
const oauthAccount = getOauthAccountInfo()
if (oauthAccount) {
if (oauthAccount.organizationUuid) attributes['organization.id'] = oauthAccount.organizationUuid
if (oauthAccount.email) attributes['user.email'] = oauthAccount.email
}
return attributes
}
默认情况下排除版本,以防止客户仪表板中出现高基数时间序列爆炸。 企业用户可以使用以下命令重新启用它 OTEL_METRICS_INCLUDE_VERSION=1.
分析在四种情况下被禁用,已签入 isAnalyticsDisabled():
// services/analytics/config.ts
export function isAnalyticsDisabled(): boolean {
return (
process.env.NODE_ENV === 'test' ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || // AWS Bedrock
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || // Google Vertex
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || // Azure Foundry
isTelemetryDisabled() // privacy setting
)
}
isFeedbackSurveyDisabled(),这确实 not 阻止 3P 提供商。 该调查是本地 UI 提示,没有文字记录数据; 企业客户通过 OTEL 而不是 Anthropic 的内部管道捕获响应。
Auth-Aware 事件调度
1P 导出器在每次 POST 之前探测身份验证状态。 如果 OAuth 令牌已过期,则缺少 user:profile 范围,或者信任对话框尚未被接受,它会回退到发送没有身份验证标头的事件。 401 响应还会触发无需身份验证的自动重试。 事件永远不会仅仅因为身份验证不可用而被丢弃——它们会降级为未经身份验证的传递。
| Condition | Behaviour | Recovery |
|---|---|---|
| 不接受信任对话框 | 跳过身份验证标头 | 接受信任后自动处理下一个事件 |
| OAuth 令牌已过期 | 跳过身份验证标头 | 令牌刷新; 下一批使用新的标头 |
| 没有用户:配置文件范围 | 跳过身份验证标头 | 服务密钥会话 - 预计未经身份验证 |
| 401响应 | 无需授权立即重试 | 对调用者透明 |
| 5xx/超时 | 磁盘队列+二次退避 | 最多尝试 8 次; 此后事件下降 |
| 终止开关已激活 | 扔; 所有批次都排队到磁盘 | 当 GrowthBook 缓存清除标志时恢复 |
要点
- 每个代币使用事件都会流经
addToTotalSessionCost()它在单个同步调用中扇出到三个目的地:内存累加器、OTel 计数器和分析事件日志。 - 分析接收器是无依赖性的,并使用队列然后排出模式,因此在启动过程中不会丢失任何事件,无论 GrowthBook 或 Datadog 初始化需要多长时间。
- Datadog 和 1P 管道接收不同的有效负载:Datadog 获取
_PROTO_*发货前钥匙已被拔除; 1P 导出器接收完整负载并将 PII 标记值路由到特权 BigQuery 列。 - The
never-键入的标记类型(AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)创建编译时书面记录 - 没有运行时强制执行,但每个日志记录调用都需要代码审阅者必须批准的显式转换。 - GrowthBook 控制三个独立的维度:哪些事件到达 Datadog(功能门)、每种事件类型的采样率(动态配置)以及是否完全终止任何接收器(每个接收器终止开关)。
- 1P
LoggerProvider从未在全球注册 - 客户 OTLP 端点和内部 Anthropic 分析在提供商级别完全隔离。 - 失败的事件将作为仅附加 JSONL 文件持久保留在磁盘上,通过二次退避重试,并在进程崩溃时幸存下来。 永久丢弃之前最多尝试 8 次。
- 会话成本状态在每次退出时都会序列化为项目配置 JSON,并在恢复时重新水合 — 成本在整个过程中正确累积
/resume会话数不重复计算。
知识检查
Q1. When logEvent() 之前被调用 attachAnalyticsSink() 已经运行了,事件会发生什么?
Q2. 什么是 shouldSampleEvent() 当 GrowthBook 配置没有事件名称条目时返回?
Q3. 为什么是 AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 类型定义为 never?
Q4. 下列哪项正确描述了 1P OTel 之间的关系 LoggerProvider 以及面向客户的 LoggerProvider?
Q5. 当事件发生时会发生什么 firstParty 接收器终止开关(tengu_frond_boric)被设置为 true?
Q6. 会话成本状态在退出时保存并在恢复时恢复。 什么可以防止从不同的会话恢复成本?
Q7. The getUserBucket() datadog.ts 中的函数有何用途?