merge: integrate telemetry removal

This commit is contained in:
paoloanzn committed 2026-03-31 16:11:59 +02:00
commit 7667a9c510
9 files changed
+168 -3300

No files matched your search

+3 -131
View File
@@ -1,11 +1,8 @@
import { profileCheckpoint } from '../utils/startupProfiler.js'
import '../bootstrap/state.js'
import '../utils/config.js'
import type { Attributes, MetricOptions } from '@opentelemetry/api'
import memoize from 'lodash-es/memoize.js'
import { getIsNonInteractiveSession } from 'src/bootstrap/state.js'
import type { AttributedCounter } from '../bootstrap/state.js'
import { getSessionCounter, setMeter } from '../bootstrap/state.js'
import { shutdownLspServerManager } from '../services/lsp/manager.js'
import { populateOAuthAccountInfoIfNeeded } from '../services/oauth/client.js'
import {
@@ -15,7 +12,6 @@ import {
import {
initializeRemoteManagedSettingsLoadingPromise,
isEligibleForRemoteManagedSettings,
waitForRemoteManagedSettingsToLoad,
} from '../services/remoteManagedSettings/index.js'
import { preconnectAnthropicApi } from '../utils/apiPreconnect.js'
import { applyExtraCACertsFromConfig } from '../utils/caCertsConfig.js'
@@ -26,14 +22,13 @@ import { detectCurrentRepository } from '../utils/detectRepository.js'
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
import { initJetBrainsDetection } from '../utils/envDynamic.js'
import { isEnvTruthy } from '../utils/envUtils.js'
import { ConfigParseError, errorMessage } from '../utils/errors.js'
import { ConfigParseError } from '../utils/errors.js'
// showInvalidConfigDialog is dynamically imported in the error path to avoid loading React at init
import {
gracefulShutdownSync,
setupGracefulShutdown,
} from '../utils/gracefulShutdown.js'
import {
applyConfigEnvironmentVariables,
applySafeConfigEnvironmentVariables,
} from '../utils/managedEnv.js'
import { configureGlobalMTLS } from '../utils/mtls.js'
@@ -41,19 +36,9 @@ import {
ensureScratchpadDir,
isScratchpadEnabled,
} from '../utils/permissions/filesystem.js'
// initializeTelemetry is loaded lazily via import() in setMeterState() to defer
// ~400KB of OpenTelemetry + protobuf modules until telemetry is actually initialized.
// gRPC exporters (~700KB via @grpc/grpc-js) are further lazy-loaded within instrumentation.ts.
import { configureGlobalAgents } from '../utils/proxy.js'
import { isBetaTracingEnabled } from '../utils/telemetry/betaSessionTracing.js'
import { getTelemetryAttributes } from '../utils/telemetryAttributes.js'
import { setShellIfWindows } from '../utils/windowsPaths.js'
// initialize1PEventLogging is dynamically imported to defer OpenTelemetry sdk-logs/resources
// Track if telemetry has been initialized to prevent double initialization
let telemetryInitialized = false
export const init = memoize(async (): Promise<void> => {
const initStartTime = Date.now()
logForDiagnosticsNoPII('info', 'init_started')
@@ -87,24 +72,6 @@ export const init = memoize(async (): Promise<void> => {
setupGracefulShutdown()
profileCheckpoint('init_after_graceful_shutdown')
// Initialize 1P event logging (no security concerns, but deferred to avoid
// loading OpenTelemetry sdk-logs at startup). growthbook.js is already in
// the module cache by this point (firstPartyEventLogger imports it), so the
// second dynamic import adds no load cost.
void Promise.all([
import('../services/analytics/firstPartyEventLogger.js'),
import('../services/analytics/growthbook.js'),
]).then(([fp, gb]) => {
fp.initialize1PEventLogging()
// Rebuild the logger provider if tengu_1p_event_batch_config changes
// mid-session. Change detection (isEqual) is inside the handler so
// unchanged refreshes are no-ops.
gb.onGrowthBookRefresh(() => {
void fp.reinitialize1PEventLoggingIfConfigChanged()
})
})
profileCheckpoint('init_after_1p_event_logging')
// Populate OAuth account info if it is not already cached in config. This is needed since the
// OAuth account info may not be populated when logging in through the VSCode extension.
void populateOAuthAccountInfoIfNeeded()
@@ -238,103 +205,8 @@ export const init = memoize(async (): Promise<void> => {
})
/**
* Initialize telemetry after trust has been granted.
* For remote-settings-eligible users, waits for settings to load (non-blocking),
* then re-applies env vars (to include remote settings) before initializing telemetry.
* For non-eligible users, initializes telemetry immediately.
* This should only be called once, after the trust dialog has been accepted.
* The OSS build does not initialize product telemetry after trust.
*/
export function initializeTelemetryAfterTrust(): void {
if (isEligibleForRemoteManagedSettings()) {
// For SDK/headless mode with beta tracing, initialize eagerly first
// to ensure the tracer is ready before the first query runs.
// The async path below will still run but doInitializeTelemetry() guards against double init.
if (getIsNonInteractiveSession() && isBetaTracingEnabled()) {
void doInitializeTelemetry().catch(error => {
logForDebugging(
`[3P telemetry] Eager telemetry init failed (beta tracing): ${errorMessage(error)}`,
{ level: 'error' },
)
})
}
logForDebugging(
'[3P telemetry] Waiting for remote managed settings before telemetry init',
)
void waitForRemoteManagedSettingsToLoad()
.then(async () => {
logForDebugging(
'[3P telemetry] Remote managed settings loaded, initializing telemetry',
)
// Re-apply env vars to pick up remote settings before initializing telemetry.
applyConfigEnvironmentVariables()
await doInitializeTelemetry()
})
.catch(error => {
logForDebugging(
`[3P telemetry] Telemetry init failed (remote settings path): ${errorMessage(error)}`,
{ level: 'error' },
)
})
} else {
void doInitializeTelemetry().catch(error => {
logForDebugging(
`[3P telemetry] Telemetry init failed: ${errorMessage(error)}`,
{ level: 'error' },
)
})
}
}
async function doInitializeTelemetry(): Promise<void> {
if (telemetryInitialized) {
// Already initialized, nothing to do
return
}
// Set flag before init to prevent double initialization
telemetryInitialized = true
try {
await setMeterState()
} catch (error) {
// Reset flag on failure so subsequent calls can retry
telemetryInitialized = false
throw error
}
}
async function setMeterState(): Promise<void> {
// Lazy-load instrumentation to defer ~400KB of OpenTelemetry + protobuf
const { initializeTelemetry } = await import(
'../utils/telemetry/instrumentation.js'
)
// Initialize customer OTLP telemetry (metrics, logs, traces)
const meter = await initializeTelemetry()
if (meter) {
// Create factory function for attributed counters
const createAttributedCounter = (
name: string,
options: MetricOptions,
): AttributedCounter => {
const counter = meter?.createCounter(name, options)
return {
add(value: number, additionalAttributes: Attributes = {}) {
// Always fetch fresh telemetry attributes to ensure they're up to date
const currentAttributes = getTelemetryAttributes()
const mergedAttributes = {
...currentAttributes,
...additionalAttributes,
}
counter?.add(value, mergedAttributes)
},
}
}
setMeter(meter, createAttributedCounter)
// Increment session counter here because the startup telemetry path
// runs before this async initialization completes, so the counter
// would be null there.
getSessionCounter()?.add(1)
}
return
}
+8 -303
View File
@@ -1,307 +1,12 @@
import axios from 'axios'
import { createHash } from 'crypto'
import memoize from 'lodash-es/memoize.js'
import { getOrCreateUserID } from '../../utils/config.js'
import { logError } from '../../utils/log.js'
import { getCanonicalName } from '../../utils/model/model.js'
import { getAPIProvider } from '../../utils/model/providers.js'
import { MODEL_COSTS } from '../../utils/modelCost.js'
import { isAnalyticsDisabled } from './config.js'
import { getEventMetadata } from './metadata.js'
const DATADOG_LOGS_ENDPOINT =
'https://http-intake.logs.us5.datadoghq.com/api/v2/logs'
const DATADOG_CLIENT_TOKEN = 'pubbbf48e6d78dae54bceaa4acf463299bf'
const DEFAULT_FLUSH_INTERVAL_MS = 15000
const MAX_BATCH_SIZE = 100
const NETWORK_TIMEOUT_MS = 5000
const DATADOG_ALLOWED_EVENTS = new Set([
'chrome_bridge_connection_succeeded',
'chrome_bridge_connection_failed',
'chrome_bridge_disconnected',
'chrome_bridge_tool_call_completed',
'chrome_bridge_tool_call_error',
'chrome_bridge_tool_call_started',
'chrome_bridge_tool_call_timeout',
'tengu_api_error',
'tengu_api_success',
'tengu_brief_mode_enabled',
'tengu_brief_mode_toggled',
'tengu_brief_send',
'tengu_cancel',
'tengu_compact_failed',
'tengu_exit',
'tengu_flicker',
'tengu_init',
'tengu_model_fallback_triggered',
'tengu_oauth_error',
'tengu_oauth_success',
'tengu_oauth_token_refresh_failure',
'tengu_oauth_token_refresh_success',
'tengu_oauth_token_refresh_lock_acquiring',
'tengu_oauth_token_refresh_lock_acquired',
'tengu_oauth_token_refresh_starting',
'tengu_oauth_token_refresh_completed',
'tengu_oauth_token_refresh_lock_releasing',
'tengu_oauth_token_refresh_lock_released',
'tengu_query_error',
'tengu_session_file_read',
'tengu_started',
'tengu_tool_use_error',
'tengu_tool_use_granted_in_prompt_permanent',
'tengu_tool_use_granted_in_prompt_temporary',
'tengu_tool_use_rejected_in_prompt',
'tengu_tool_use_success',
'tengu_uncaught_exception',
'tengu_unhandled_rejection',
'tengu_voice_recording_started',
'tengu_voice_toggled',
'tengu_team_mem_sync_pull',
'tengu_team_mem_sync_push',
'tengu_team_mem_sync_started',
'tengu_team_mem_entries_capped',
])
const TAG_FIELDS = [
'arch',
'clientType',
'errorType',
'http_status_range',
'http_status',
'kairosActive',
'model',
'platform',
'provider',
'skillMode',
'subscriptionType',
'toolName',
'userBucket',
'userType',
'version',
'versionBase',
]
function camelToSnakeCase(str: string): string {
return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`)
}
type DatadogLog = {
ddsource: string
ddtags: string
message: string
service: string
hostname: string
[key: string]: unknown
}
let logBatch: DatadogLog[] = []
let flushTimer: NodeJS.Timeout | null = null
let datadogInitialized: boolean | null = null
async function flushLogs(): Promise<void> {
if (logBatch.length === 0) return
const logsToSend = logBatch
logBatch = []
try {
await axios.post(DATADOG_LOGS_ENDPOINT, logsToSend, {
headers: {
'Content-Type': 'application/json',
'DD-API-KEY': DATADOG_CLIENT_TOKEN,
},
timeout: NETWORK_TIMEOUT_MS,
})
} catch (error) {
logError(error)
}
}
function scheduleFlush(): void {
if (flushTimer) return
flushTimer = setTimeout(() => {
flushTimer = null
void flushLogs()
}, getFlushIntervalMs()).unref()
}
export const initializeDatadog = memoize(async (): Promise<boolean> => {
if (isAnalyticsDisabled()) {
datadogInitialized = false
return false
}
try {
datadogInitialized = true
return true
} catch (error) {
logError(error)
datadogInitialized = false
return false
}
})
/**
* Flush remaining Datadog logs and shut down.
* Called from gracefulShutdown() before process.exit() since
* forceExit() prevents the beforeExit handler from firing.
* Datadog logging is intentionally disabled in the OSS build.
*/
export async function shutdownDatadog(): Promise<void> {
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
await flushLogs()
}
// NOTE: use via src/services/analytics/index.ts > logEvent
export const initializeDatadog = async (): Promise<boolean> => false
export async function shutdownDatadog(): Promise<void> {}
export async function trackDatadogEvent(
eventName: string,
properties: { [key: string]: boolean | number | undefined },
): Promise<void> {
if (process.env.NODE_ENV !== 'production') {
return
}
// Don't send events for 3P providers (Bedrock, Vertex, Foundry)
if (getAPIProvider() !== 'firstParty') {
return
}
// Fast path: use cached result if available to avoid await overhead
let initialized = datadogInitialized
if (initialized === null) {
initialized = await initializeDatadog()
}
if (!initialized || !DATADOG_ALLOWED_EVENTS.has(eventName)) {
return
}
try {
const metadata = await getEventMetadata({
model: properties.model,
betas: properties.betas,
})
// Destructure to avoid duplicate envContext (once nested, once flattened)
const { envContext, ...restMetadata } = metadata
const allData: Record<string, unknown> = {
...restMetadata,
...envContext,
...properties,
userBucket: getUserBucket(),
}
// Normalize MCP tool names to "mcp" for cardinality reduction
if (
typeof allData.toolName === 'string' &&
allData.toolName.startsWith('mcp__')
) {
allData.toolName = 'mcp'
}
// Normalize model names for cardinality reduction (external users only)
if (process.env.USER_TYPE !== 'ant' && typeof allData.model === 'string') {
const shortName = getCanonicalName(allData.model.replace(/\[1m]$/i, ''))
allData.model = shortName in MODEL_COSTS ? shortName : 'other'
}
// Truncate dev version to base + date (remove timestamp and sha for cardinality reduction)
// e.g. "2.0.53-dev.20251124.t173302.sha526cc6a" -> "2.0.53-dev.20251124"
if (typeof allData.version === 'string') {
allData.version = allData.version.replace(
/^(\d+\.\d+\.\d+-dev\.\d{8})\.t\d+\.sha[a-f0-9]+$/,
'$1',
)
}
// Transform status to http_status and http_status_range to avoid Datadog reserved field
if (allData.status !== undefined && allData.status !== null) {
const statusCode = String(allData.status)
allData.http_status = statusCode
// Determine status range (1xx, 2xx, 3xx, 4xx, 5xx)
const firstDigit = statusCode.charAt(0)
if (firstDigit >= '1' && firstDigit <= '5') {
allData.http_status_range = `${firstDigit}xx`
}
// Remove original status field to avoid conflict with Datadog's reserved field
delete allData.status
}
// Build ddtags with high-cardinality fields for filtering.
// event:<name> is prepended so the event name is searchable via the
// log search API — the `message` field (where eventName also lives)
// is a DD reserved field and is NOT queryable from dashboard widget
// queries or the aggregation API. See scripts/release/MONITORING.md.
const allDataRecord = allData
const tags = [
`event:${eventName}`,
...TAG_FIELDS.filter(
field =>
allDataRecord[field] !== undefined && allDataRecord[field] !== null,
).map(field => `${camelToSnakeCase(field)}:${allDataRecord[field]}`),
]
const log: DatadogLog = {
ddsource: 'nodejs',
ddtags: tags.join(','),
message: eventName,
service: 'claude-code',
hostname: 'claude-code',
env: process.env.USER_TYPE,
}
// Add all fields as searchable attributes (not duplicated in tags)
for (const [key, value] of Object.entries(allData)) {
if (value !== undefined && value !== null) {
log[camelToSnakeCase(key)] = value
}
}
logBatch.push(log)
// Flush immediately if batch is full, otherwise schedule
if (logBatch.length >= MAX_BATCH_SIZE) {
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
void flushLogs()
} else {
scheduleFlush()
}
} catch (error) {
logError(error)
}
}
const NUM_USER_BUCKETS = 30
/**
* Gets a 'bucket' that the user ID falls into.
*
* For alerting purposes, we want to alert on the number of users impacted
* by an issue, rather than the number of events- often a small number of users
* can generate a large number of events (e.g. due to retries). To approximate
* this without ruining cardinality by counting user IDs directly, we hash the user ID
* and assign it to one of a fixed number of buckets.
*
* This allows us to estimate the number of unique users by counting unique buckets,
* while preserving user privacy and reducing cardinality.
*/
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
})
function getFlushIntervalMs(): number {
// Allow tests to override to not block on the default flush interval.
return (
parseInt(process.env.CLAUDE_CODE_DATADOG_FLUSH_INTERVAL_MS || '', 10) ||
DEFAULT_FLUSH_INTERVAL_MS
)
}
_eventName: string,
_properties: { [key: string]: boolean | number | undefined },
): Promise<void> {}
+19 -420
View File
@@ -1,237 +1,37 @@
import type { AnyValueMap, Logger, logs } from '@opentelemetry/api-logs'
import { resourceFromAttributes } from '@opentelemetry/resources'
import {
BatchLogRecordProcessor,
LoggerProvider,
} from '@opentelemetry/sdk-logs'
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions'
import { randomUUID } from 'crypto'
import { isEqual } from 'lodash-es'
import { getOrCreateUserID } from '../../utils/config.js'
import { logForDebugging } from '../../utils/debug.js'
import { logError } from '../../utils/log.js'
import { getPlatform, getWslVersion } from '../../utils/platform.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { profileCheckpoint } from '../../utils/startupProfiler.js'
import { getCoreUserData } from '../../utils/user.js'
import { isAnalyticsDisabled } from './config.js'
import { FirstPartyEventLoggingExporter } from './firstPartyEventLoggingExporter.js'
import type { GrowthBookUserAttributes } from './growthbook.js'
import { getDynamicConfig_CACHED_MAY_BE_STALE } from './growthbook.js'
import { getEventMetadata } from './metadata.js'
import { isSinkKilled } from './sinkKillswitch.js'
/**
* Configuration for sampling individual event types.
* Each event name maps to an object containing sample_rate (0-1).
* Events not in the config are logged at 100% rate.
* First-party event logging is intentionally disabled in the OSS build.
*
* This file preserves the public API used throughout the CLI while ensuring no
* telemetry providers, exporters, or batching logic are initialized.
*/
import type { GrowthBookUserAttributes } from './growthbook.js'
export type EventSamplingConfig = {
[eventName: string]: {
sample_rate: number
}
}
const EVENT_SAMPLING_CONFIG_NAME = 'tengu_event_sampling_config'
/**
* Get the event sampling configuration from GrowthBook.
* Uses cached value if available, updates cache in background.
*/
export function getEventSamplingConfig(): EventSamplingConfig {
return getDynamicConfig_CACHED_MAY_BE_STALE<EventSamplingConfig>(
EVENT_SAMPLING_CONFIG_NAME,
{},
)
return {}
}
/**
* Determine if an event should be sampled based on its sample rate.
* Returns the sample rate if sampled, null if not sampled.
*
* @param eventName - Name of the event to check
* @returns The sample_rate if event should be logged, null if it should be dropped
*/
export function shouldSampleEvent(eventName: string): number | null {
const config = getEventSamplingConfig()
const eventConfig = config[eventName]
// If no config for this event, log at 100% rate (no sampling)
if (!eventConfig) {
return null
}
const sampleRate = eventConfig.sample_rate
// Validate sample rate is in valid range
if (typeof sampleRate !== 'number' || sampleRate < 0 || sampleRate > 1) {
return null
}
// Sample rate of 1 means log everything (no need to add metadata)
if (sampleRate >= 1) {
return null
}
// Sample rate of 0 means drop everything
if (sampleRate <= 0) {
return 0
}
// Randomly decide whether to sample this event
return Math.random() < sampleRate ? sampleRate : 0
export function shouldSampleEvent(_eventName: string): number | null {
return 0
}
const BATCH_CONFIG_NAME = 'tengu_1p_event_batch_config'
type BatchConfig = {
scheduledDelayMillis?: number
maxExportBatchSize?: number
maxQueueSize?: number
skipAuth?: boolean
maxAttempts?: number
path?: string
baseUrl?: string
}
function getBatchConfig(): BatchConfig {
return getDynamicConfig_CACHED_MAY_BE_STALE<BatchConfig>(
BATCH_CONFIG_NAME,
{},
)
}
export async function shutdown1PEventLogging(): Promise<void> {}
// Module-local state for event logging (not exposed globally)
let firstPartyEventLogger: ReturnType<typeof logs.getLogger> | null = null
let firstPartyEventLoggerProvider: LoggerProvider | null = null
// Last batch config used to construct the provider — used by
// reinitialize1PEventLoggingIfConfigChanged to decide whether a rebuild is
// needed when GrowthBook refreshes.
let lastBatchConfig: BatchConfig | null = null
/**
* Flush and shutdown the 1P event logger.
* This should be called as the final step before process exit to ensure
* all events (including late ones from API responses) are exported.
*/
export async function shutdown1PEventLogging(): Promise<void> {
if (!firstPartyEventLoggerProvider) {
return
}
try {
await firstPartyEventLoggerProvider.shutdown()
if (process.env.USER_TYPE === 'ant') {
logForDebugging('1P event logging: final shutdown complete')
}
} catch {
// Ignore shutdown errors
}
}
/**
* Check if 1P event logging is enabled.
* Respects the same opt-outs as other analytics sinks:
* - Test environment
* - Third-party cloud providers (Bedrock/Vertex)
* - Global telemetry opt-outs
* - Non-essential traffic disabled
*
* Note: Unlike BigQuery metrics, event logging does NOT check organization-level
* metrics opt-out via API. It follows the same pattern as Statsig event logging.
*/
export function is1PEventLoggingEnabled(): boolean {
// Respect standard analytics opt-outs
return !isAnalyticsDisabled()
return false
}
/**
* Log a 1st-party event for internal analytics (async version).
* Events are batched and exported to /api/event_logging/batch
*
* This enriches the event with core metadata (model, session, env context, etc.)
* at log time, similar to logEventToStatsig.
*
* @param eventName - Name of the event (e.g., 'tengu_api_query')
* @param metadata - Additional metadata for the event (intentionally no strings, to avoid accidentally logging code/filepaths)
*/
async function logEventTo1PAsync(
firstPartyEventLogger: Logger,
eventName: string,
metadata: Record<string, number | boolean | undefined> = {},
): Promise<void> {
try {
// Enrich with core metadata at log time (similar to Statsig pattern)
const coreMetadata = await getEventMetadata({
model: metadata.model,
betas: metadata.betas,
})
// Build attributes - OTel supports nested objects natively via AnyValueMap
// Cast through unknown since our nested objects are structurally compatible
// with AnyValue but TS doesn't recognize it due to missing index signatures
const attributes = {
event_name: eventName,
event_id: randomUUID(),
// Pass objects directly - no JSON serialization needed
core_metadata: coreMetadata,
user_metadata: getCoreUserData(true),
event_metadata: metadata,
} as unknown as AnyValueMap
// Add user_id if available
const userId = getOrCreateUserID()
if (userId) {
attributes.user_id = userId
}
// Debug logging when debug mode is enabled
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`[ANT-ONLY] 1P event: ${eventName} ${jsonStringify(metadata, null, 0)}`,
)
}
// Emit log record
firstPartyEventLogger.emit({
body: eventName,
attributes,
})
} catch (e) {
if (process.env.NODE_ENV === 'development') {
throw e
}
if (process.env.USER_TYPE === 'ant') {
logError(e as Error)
}
// swallow
}
}
/**
* Log a 1st-party event for internal analytics.
* Events are batched and exported to /api/event_logging/batch
*
* @param eventName - Name of the event (e.g., 'tengu_api_query')
* @param metadata - Additional metadata for the event (intentionally no strings, to avoid accidentally logging code/filepaths)
*/
export function logEventTo1P(
eventName: string,
metadata: Record<string, number | boolean | undefined> = {},
): void {
if (!is1PEventLoggingEnabled()) {
return
}
_eventName: string,
_metadata: Record<string, number | boolean | undefined> = {},
): void {}
if (!firstPartyEventLogger || isSinkKilled('firstParty')) {
return
}
// Fire and forget - don't block on metadata enrichment
void logEventTo1PAsync(firstPartyEventLogger, eventName, metadata)
}
/**
* GrowthBook experiment event data for logging
*/
export type GrowthBookExperimentData = {
experimentId: string
variationId: number
@@ -239,211 +39,10 @@ export type GrowthBookExperimentData = {
experimentMetadata?: Record<string, unknown>
}
// api.anthropic.com only serves the "production" GrowthBook environment
// (see starling/starling/cli/cli.py DEFAULT_ENVIRONMENTS). Staging and
// development environments are not exported to the prod API.
function getEnvironmentForGrowthBook(): string {
return 'production'
}
/**
* Log a GrowthBook experiment assignment event to 1P.
* Events are batched and exported to /api/event_logging/batch
*
* @param data - GrowthBook experiment assignment data
*/
export function logGrowthBookExperimentTo1P(
data: GrowthBookExperimentData,
): void {
if (!is1PEventLoggingEnabled()) {
return
}
_data: GrowthBookExperimentData,
): void {}
if (!firstPartyEventLogger || isSinkKilled('firstParty')) {
return
}
export function initialize1PEventLogging(): void {}
const userId = getOrCreateUserID()
const { accountUuid, organizationUuid } = getCoreUserData(true)
// Build attributes for GrowthbookExperimentEvent
const attributes = {
event_type: 'GrowthbookExperimentEvent',
event_id: randomUUID(),
experiment_id: data.experimentId,
variation_id: data.variationId,
...(userId && { device_id: userId }),
...(accountUuid && { account_uuid: accountUuid }),
...(organizationUuid && { organization_uuid: organizationUuid }),
...(data.userAttributes && {
session_id: data.userAttributes.sessionId,
user_attributes: jsonStringify(data.userAttributes),
}),
...(data.experimentMetadata && {
experiment_metadata: jsonStringify(data.experimentMetadata),
}),
environment: getEnvironmentForGrowthBook(),
}
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`[ANT-ONLY] 1P GrowthBook experiment: ${data.experimentId} variation=${data.variationId}`,
)
}
firstPartyEventLogger.emit({
body: 'growthbook_experiment',
attributes,
})
}
const DEFAULT_LOGS_EXPORT_INTERVAL_MS = 10000
const DEFAULT_MAX_EXPORT_BATCH_SIZE = 200
const DEFAULT_MAX_QUEUE_SIZE = 8192
/**
* Initialize 1P event logging infrastructure.
* This creates a separate LoggerProvider for internal event logging,
* independent of customer OTLP telemetry.
*
* This uses its own minimal resource configuration with just the attributes
* we need for internal analytics (service name, version, platform info).
*/
export function initialize1PEventLogging(): void {
profileCheckpoint('1p_event_logging_start')
const enabled = is1PEventLoggingEnabled()
if (!enabled) {
if (process.env.USER_TYPE === 'ant') {
logForDebugging('1P event logging not enabled')
}
return
}
// Fetch batch processor configuration from GrowthBook dynamic config
// Uses cached value if available, refreshes in background
const batchConfig = getBatchConfig()
lastBatchConfig = batchConfig
profileCheckpoint('1p_event_after_growthbook_config')
const scheduledDelayMillis =
batchConfig.scheduledDelayMillis ||
parseInt(
process.env.OTEL_LOGS_EXPORT_INTERVAL ||
DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(),
)
const maxExportBatchSize =
batchConfig.maxExportBatchSize || DEFAULT_MAX_EXPORT_BATCH_SIZE
const maxQueueSize = batchConfig.maxQueueSize || DEFAULT_MAX_QUEUE_SIZE
// Build our own resource for 1P event logging with minimal attributes
const platform = getPlatform()
const attributes: Record<string, string> = {
[ATTR_SERVICE_NAME]: 'claude-code',
[ATTR_SERVICE_VERSION]: MACRO.VERSION,
}
// Add WSL-specific attributes if running on WSL
if (platform === 'wsl') {
const wslVersion = getWslVersion()
if (wslVersion) {
attributes['wsl.version'] = wslVersion
}
}
const resource = resourceFromAttributes(attributes)
// Create a new LoggerProvider with the EventLoggingExporter
// NOTE: This is kept separate from customer telemetry logs to ensure
// internal events don't leak to customer endpoints and vice versa.
// We don't register this globally - it's only used for internal event logging.
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,
}),
],
})
// Initialize event logger from our internal provider (NOT from global API)
// IMPORTANT: We must get the logger from our local provider, not logs.getLogger()
// because logs.getLogger() returns a logger from the global provider, which is
// separate and used for customer telemetry.
firstPartyEventLogger = firstPartyEventLoggerProvider.getLogger(
'com.anthropic.claude_code.events',
MACRO.VERSION,
)
}
/**
* Rebuild the 1P event logging pipeline if the batch config changed.
* Register this with onGrowthBookRefresh so long-running sessions pick up
* changes to batch size, delay, endpoint, etc.
*
* Event-loss safety:
* 1. Null the logger first — concurrent logEventTo1P() calls hit the
* !firstPartyEventLogger guard and bail during the swap window. This drops
* a handful of events but prevents emitting to a draining provider.
* 2. forceFlush() drains the old BatchLogRecordProcessor buffer to the
* exporter. Export failures go to disk at getCurrentBatchFilePath() which
* is keyed by module-level BATCH_UUID + sessionId — unchanged across
* reinit — so the NEW exporter's disk-backed retry picks them up.
* 3. Swap to new provider/logger; old provider shutdown runs in background
* (buffer already drained, just cleanup).
*/
export async function reinitialize1PEventLoggingIfConfigChanged(): Promise<void> {
if (!is1PEventLoggingEnabled() || !firstPartyEventLoggerProvider) {
return
}
const newConfig = getBatchConfig()
if (isEqual(newConfig, lastBatchConfig)) {
return
}
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`1P event logging: ${BATCH_CONFIG_NAME} changed, reinitializing`,
)
}
const oldProvider = firstPartyEventLoggerProvider
const oldLogger = firstPartyEventLogger
firstPartyEventLogger = null
try {
await oldProvider.forceFlush()
} catch {
// Export failures are already on disk; new exporter will retry them.
}
firstPartyEventLoggerProvider = null
try {
initialize1PEventLogging()
} catch (e) {
// Restore so the next GrowthBook refresh can retry. oldProvider was
// only forceFlush()'d, not shut down — it's still functional. Without
// this, both stay null and the !firstPartyEventLoggerProvider gate at
// the top makes recovery impossible.
firstPartyEventLoggerProvider = oldProvider
firstPartyEventLogger = oldLogger
logError(e)
return
}
void oldProvider.shutdown().catch(() => {})
}
export async function reinitialize1PEventLoggingIfConfigChanged(): Promise<void> {}
+12 -145
View File
@@ -1,74 +1,22 @@
/**
* Analytics service - public API for event logging
*
* This module serves as the main entry point for analytics events in Claude CLI.
*
* DESIGN: This module has NO dependencies to avoid import cycles.
* Events are queued until attachAnalyticsSink() is called during app initialization.
* The sink handles routing to Datadog and 1P event logging.
* The open build intentionally ships without product telemetry. We keep this
* module as a compatibility boundary so existing call sites can remain
* unchanged while all analytics become inert.
*/
/**
* Marker type for verifying analytics metadata doesn't contain sensitive data
*
* This type forces explicit verification that string values being logged
* don't contain code snippets, file paths, or other sensitive information.
*
* Usage: `myString as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS`
*/
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never
/**
* Marker type for values routed to PII-tagged proto columns via `_PROTO_*`
* payload keys. The destination BQ column has privileged access controls,
* so unredacted values are acceptable — unlike general-access backends.
*
* sink.ts strips `_PROTO_*` keys before Datadog fanout; only the 1P
* exporter (firstPartyEventLoggingExporter) sees them and hoists them to the
* top-level proto field. A single stripProtoFields call guards all non-1P
* sinks — no per-sink filtering to forget.
*
* Usage: `rawName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED`
*/
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED = never
/**
* Strip `_PROTO_*` keys from a payload destined for general-access storage.
* Used by:
* - sink.ts: before Datadog fanout (never sees PII-tagged values)
* - firstPartyEventLoggingExporter: defensive strip of additional_metadata
* after hoisting known _PROTO_* keys to proto fields — prevents a future
* unrecognized _PROTO_foo from silently landing in the BQ JSON blob.
*
* Returns the input unchanged (same reference) when no _PROTO_ keys present.
*/
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
return metadata
}
// Internal type for logEvent metadata - different from the enriched EventMetadata in metadata.ts
type LogEventMetadata = { [key: string]: boolean | number | undefined }
type QueuedEvent = {
eventName: string
metadata: LogEventMetadata
async: boolean
}
/**
* Sink interface for the analytics backend
*/
export type AnalyticsSink = {
logEvent: (eventName: string, metadata: LogEventMetadata) => void
logEventAsync: (
@@ -77,97 +25,16 @@ export type AnalyticsSink = {
) => Promise<void>
}
// Event queue for events logged before sink is attached
const eventQueue: QueuedEvent[] = []
export function attachAnalyticsSink(_newSink: AnalyticsSink): void {}
// Sink - initialized during app startup
let sink: AnalyticsSink | null = null
/**
* Attach the analytics sink that will receive all events.
* Queued events are drained asynchronously via queueMicrotask to avoid
* adding latency to the startup path.
*
* Idempotent: if a sink is already attached, this is a no-op. This allows
* calling from both the preAction hook (for subcommands) and setup() (for
* the default command) without coordination.
*/
export function attachAnalyticsSink(newSink: AnalyticsSink): void {
if (sink !== null) {
return
}
sink = newSink
// Drain the queue asynchronously to avoid blocking startup
if (eventQueue.length > 0) {
const queuedEvents = [...eventQueue]
eventQueue.length = 0
// Log queue size for ants to help debug analytics initialization timing
if (process.env.USER_TYPE === 'ant') {
sink.logEvent('analytics_sink_attached', {
queued_event_count: queuedEvents.length,
})
}
queueMicrotask(() => {
for (const event of queuedEvents) {
if (event.async) {
void sink!.logEventAsync(event.eventName, event.metadata)
} else {
sink!.logEvent(event.eventName, event.metadata)
}
}
})
}
}
/**
* Log an event to analytics backends (synchronous)
*
* Events may be sampled based on the 'tengu_event_sampling_config' dynamic config.
* When sampled, the sample_rate is added to the event metadata.
*
* If no sink is attached, events are queued and drained when the sink attaches.
*/
export function logEvent(
eventName: string,
// intentionally no strings unless AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
// to avoid accidentally logging code/filepaths
metadata: LogEventMetadata,
): void {
if (sink === null) {
eventQueue.push({ eventName, metadata, async: false })
return
}
sink.logEvent(eventName, metadata)
}
_eventName: string,
_metadata: LogEventMetadata,
): void {}
/**
* Log an event to analytics backends (asynchronous)
*
* Events may be sampled based on the 'tengu_event_sampling_config' dynamic config.
* When sampled, the sample_rate is added to the event metadata.
*
* If no sink is attached, events are queued and drained when the sink attaches.
*/
export async function logEventAsync(
eventName: string,
// intentionally no strings, to avoid accidentally logging code/filepaths
metadata: LogEventMetadata,
): Promise<void> {
if (sink === null) {
eventQueue.push({ eventName, metadata, async: true })
return
}
await sink.logEventAsync(eventName, metadata)
}
_eventName: string,
_metadata: LogEventMetadata,
): Promise<void> {}
/**
* Reset analytics state for testing purposes only.
* @internal
*/
export function _resetForTesting(): void {
sink = null
eventQueue.length = 0
}
export function _resetForTesting(): void {}
+4 -108
View File
@@ -1,114 +1,10 @@
/**
* Analytics sink implementation
*
* This module contains the actual analytics routing logic and should be
* initialized during app startup. It routes events to Datadog and 1P event
* logging.
*
* Usage: Call initializeAnalyticsSink() during app startup to attach the sink.
* Telemetry sinks are disabled in this build. The exported functions remain so
* startup code does not need to special-case the OSS variant.
*/
import { trackDatadogEvent } from './datadog.js'
import { logEventTo1P, shouldSampleEvent } from './firstPartyEventLogger.js'
import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from './growthbook.js'
import { attachAnalyticsSink, stripProtoFields } from './index.js'
import { isSinkKilled } from './sinkKillswitch.js'
export function initializeAnalyticsGates(): void {}
// Local type matching the logEvent metadata signature
type LogEventMetadata = { [key: string]: boolean | number | undefined }
const DATADOG_GATE_NAME = 'tengu_log_datadog_events'
// Module-level gate state - starts undefined, initialized during startup
let isDatadogGateEnabled: boolean | undefined = undefined
/**
* Check if Datadog tracking is enabled.
* Falls back to cached value from previous session if not yet initialized.
*/
function shouldTrackDatadog(): boolean {
if (isSinkKilled('datadog')) {
return false
}
if (isDatadogGateEnabled !== undefined) {
return isDatadogGateEnabled
}
// Fallback to cached value from previous session
try {
return checkStatsigFeatureGate_CACHED_MAY_BE_STALE(DATADOG_GATE_NAME)
} catch {
return false
}
}
/**
* Log an event (synchronous implementation)
*/
function logEventImpl(eventName: string, metadata: LogEventMetadata): void {
// Check if this event should be sampled
const sampleResult = shouldSampleEvent(eventName)
// If sample result is 0, the event was not selected for logging
if (sampleResult === 0) {
return
}
// If sample result is a positive number, add it to metadata
const metadataWithSampleRate =
sampleResult !== null
? { ...metadata, sample_rate: sampleResult }
: metadata
if (shouldTrackDatadog()) {
// Datadog is a general-access backend — strip _PROTO_* keys
// (unredacted PII-tagged values meant only for the 1P privileged column).
void trackDatadogEvent(eventName, stripProtoFields(metadataWithSampleRate))
}
// 1P receives the full payload including _PROTO_* — the exporter
// destructures and routes those keys to proto fields itself.
logEventTo1P(eventName, metadataWithSampleRate)
}
/**
* Log an event (asynchronous implementation)
*
* With Segment removed the two remaining sinks are fire-and-forget, so this
* just wraps the sync impl — kept to preserve the sink interface contract.
*/
function logEventAsyncImpl(
eventName: string,
metadata: LogEventMetadata,
): Promise<void> {
logEventImpl(eventName, metadata)
return Promise.resolve()
}
/**
* Initialize analytics gates during startup.
*
* Updates gate values from server. Early events use cached values from previous
* session to avoid data loss during initialization.
*
* Called from main.tsx during setupBackend().
*/
export function initializeAnalyticsGates(): void {
isDatadogGateEnabled =
checkStatsigFeatureGate_CACHED_MAY_BE_STALE(DATADOG_GATE_NAME)
}
/**
* Initialize the analytics sink.
*
* Call this during app startup to attach the analytics backend.
* Any events logged before this is called will be queued and drained.
*
* Idempotent: safe to call multiple times (subsequent calls are no-ops).
*/
export function initializeAnalyticsSink(): void {
attachAnalyticsSink({
logEvent: logEventImpl,
logEventAsync: logEventAsyncImpl,
})
}
export function initializeAnalyticsSink(): void {}
+41 -468
View File
@@ -1,491 +1,64 @@
/**
* Beta Session Tracing for Claude Code
*
* This module contains beta tracing features enabled when
* ENABLE_BETA_TRACING_DETAILED=1 and BETA_TRACING_ENDPOINT are set.
*
* For external users, tracing is enabled in SDK/headless mode, or in
* interactive mode when the org is allowlisted via the
* tengu_trace_lantern GrowthBook gate.
* For ant users, tracing is enabled in all modes.
*
* Visibility Rules:
* | Content | External | Ant |
* |------------------|----------|------|
* | System prompts | ✅ | ✅ |
* | Model output | ✅ | ✅ |
* | Thinking output | ❌ | ✅ |
* | Tools | ✅ | ✅ |
* | new_context | ✅ | ✅ |
*
* Features:
* - Per-agent message tracking with hash-based deduplication
* - System prompt logging (once per unique hash)
* - Hook execution spans
* - Detailed new_context attributes for LLM requests
* Beta tracing is disabled in the OSS build.
*/
import type { Span } from '@opentelemetry/api'
import { createHash } from 'crypto'
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'
import type { AssistantMessage, UserMessage } from '../../types/message.js'
import { isEnvTruthy } from '../envUtils.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { logOTelEvent } from './events.js'
// Message type for API calls (UserMessage or AssistantMessage)
type APIMessage = UserMessage | AssistantMessage
/**
* Track hashes we've already logged this session (system prompts, tools, etc).
*
* WHY: System prompts and tool schemas are large and rarely change within a session.
* Sending full content on every request would be wasteful. Instead, we hash and
* only log the full content once per unique hash.
*/
const seenHashes = new Set<string>()
/**
* Track the last reported message hash per querySource (agent) for incremental context.
*
* WHY: When debugging traces, we want to see what NEW information was added each turn,
* not the entire conversation history (which can be huge). By tracking the last message
* we reported per agent, we can compute and send only the delta (new messages since
* the last request). This is tracked per-agent (querySource) because different agents
* (main thread, subagents, warmup requests) have independent conversation contexts.
*/
const lastReportedMessageHash = new Map<string, string>()
/**
* Clear tracking state after compaction.
* Old hashes are irrelevant once messages have been replaced.
*/
export function clearBetaTracingState(): void {
seenHashes.clear()
lastReportedMessageHash.clear()
}
const MAX_CONTENT_SIZE = 60 * 1024 // 60KB (Honeycomb limit is 64KB, staying safe)
/**
* Check if beta detailed tracing is enabled.
* - Requires ENABLE_BETA_TRACING_DETAILED=1 and BETA_TRACING_ENDPOINT
* - For external users, enabled in SDK/headless mode OR when org is
* allowlisted via the tengu_trace_lantern GrowthBook gate
*/
export function isBetaTracingEnabled(): boolean {
const baseEnabled =
isEnvTruthy(process.env.ENABLE_BETA_TRACING_DETAILED) &&
Boolean(process.env.BETA_TRACING_ENDPOINT)
if (!baseEnabled) {
return false
}
// For external users, enable in SDK/headless mode OR when org is allowlisted.
// Gate reads from disk cache, so first run after allowlisting returns false;
// works from second run onward (same behavior as enhanced_telemetry_beta).
if (process.env.USER_TYPE !== 'ant') {
return (
getIsNonInteractiveSession() ||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_trace_lantern', false)
)
}
return true
}
/**
* Truncate content to fit within Honeycomb limits.
*/
export function truncateContent(
content: string,
maxSize: number = MAX_CONTENT_SIZE,
): { content: string; truncated: boolean } {
if (content.length <= maxSize) {
return { content, truncated: false }
}
return {
content:
content.slice(0, maxSize) +
'\n\n[TRUNCATED - Content exceeds 60KB limit]',
truncated: true,
}
}
/**
* Generate a short hash (first 12 hex chars of SHA-256).
*/
function shortHash(content: string): string {
return createHash('sha256').update(content).digest('hex').slice(0, 12)
}
/**
* Generate a hash for a system prompt.
*/
function hashSystemPrompt(systemPrompt: string): string {
return `sp_${shortHash(systemPrompt)}`
}
/**
* Generate a hash for a message based on its content.
*/
function hashMessage(message: APIMessage): string {
const content = jsonStringify(message.message.content)
return `msg_${shortHash(content)}`
}
// Regex to detect content wrapped in <system-reminder> tags
const SYSTEM_REMINDER_REGEX =
/^<system-reminder>\n?([\s\S]*?)\n?<\/system-reminder>$/
/**
* Check if text is entirely a system reminder (wrapped in <system-reminder> tags).
* Returns the inner content if it is, null otherwise.
*/
function extractSystemReminderContent(text: string): string | null {
const match = text.trim().match(SYSTEM_REMINDER_REGEX)
return match && match[1] ? match[1].trim() : null
}
/**
* Result of formatting messages - separates regular content from system reminders.
*/
interface FormattedMessages {
contextParts: string[]
systemReminders: string[]
}
/**
* Format user messages for new_context display, separating system reminders.
* Only handles user messages (assistant messages are filtered out before this is called).
*/
function formatMessagesForContext(messages: UserMessage[]): FormattedMessages {
const contextParts: string[] = []
const systemReminders: string[] = []
for (const message of messages) {
const content = message.message.content
if (typeof content === 'string') {
const reminderContent = extractSystemReminderContent(content)
if (reminderContent) {
systemReminders.push(reminderContent)
} else {
contextParts.push(`[USER]\n${content}`)
}
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text') {
const reminderContent = extractSystemReminderContent(block.text)
if (reminderContent) {
systemReminders.push(reminderContent)
} else {
contextParts.push(`[USER]\n${block.text}`)
}
} else if (block.type === 'tool_result') {
const resultContent =
typeof block.content === 'string'
? block.content
: jsonStringify(block.content)
// Tool results can also contain system reminders (e.g., malware warning)
const reminderContent = extractSystemReminderContent(resultContent)
if (reminderContent) {
systemReminders.push(reminderContent)
} else {
contextParts.push(
`[TOOL RESULT: ${block.tool_use_id}]\n${resultContent}`,
)
}
}
}
}
}
return { contextParts, systemReminders }
export interface Span {
setAttribute(name: string, value: string | number | boolean): void
setAttributes(attributes: Record<string, string | number | boolean>): void
addEvent(
name: string,
attributes?: Record<string, string | number | boolean>,
): void
end(): void
recordException(error: unknown): void
}
export interface LLMRequestNewContext {
/** System prompt (typically only on first request or if changed) */
systemPrompt?: string
/** Query source identifying the agent/purpose (e.g., 'repl_main_thread', 'agent:builtin') */
querySource?: string
/** Tool schemas sent with the request */
tools?: string
}
/**
* Add beta attributes to an interaction span.
* Adds new_context with the user prompt.
*/
export function clearBetaTracingState(): void {}
export function isBetaTracingEnabled(): boolean {
return false
}
export function truncateContent(
content: string,
_maxSize?: number,
): { content: string; truncated: boolean } {
return { content, truncated: false }
}
export function addBetaInteractionAttributes(
span: Span,
userPrompt: string,
): void {
if (!isBetaTracingEnabled()) {
return
}
_span: Span,
_userPrompt: string,
): void {}
const { content: truncatedPrompt, truncated } = truncateContent(
`[USER PROMPT]\n${userPrompt}`,
)
span.setAttributes({
new_context: truncatedPrompt,
...(truncated && {
new_context_truncated: true,
new_context_original_length: userPrompt.length,
}),
})
}
/**
* Add beta attributes to an LLM request span.
* Handles system prompt logging and new_context computation.
*/
export function addBetaLLMRequestAttributes(
span: Span,
newContext?: LLMRequestNewContext,
messagesForAPI?: APIMessage[],
): void {
if (!isBetaTracingEnabled()) {
return
}
_span: Span,
_newContext?: LLMRequestNewContext,
_messagesForAPI?: unknown[],
): void {}
// Add system prompt info to the span
if (newContext?.systemPrompt) {
const promptHash = hashSystemPrompt(newContext.systemPrompt)
const preview = newContext.systemPrompt.slice(0, 500)
// Always add hash, preview, and length to the span
span.setAttribute('system_prompt_hash', promptHash)
span.setAttribute('system_prompt_preview', preview)
span.setAttribute('system_prompt_length', newContext.systemPrompt.length)
// Log the full system prompt only once per unique hash this session
if (!seenHashes.has(promptHash)) {
seenHashes.add(promptHash)
// Truncate for the log if needed
const { content: truncatedPrompt, truncated } = truncateContent(
newContext.systemPrompt,
)
void logOTelEvent('system_prompt', {
system_prompt_hash: promptHash,
system_prompt: truncatedPrompt,
system_prompt_length: String(newContext.systemPrompt.length),
...(truncated && { system_prompt_truncated: 'true' }),
})
}
}
// Add tools info to the span
if (newContext?.tools) {
try {
const toolsArray = jsonParse(newContext.tools) as Record<
string,
unknown
>[]
// Build array of {name, hash} for each tool
const toolsWithHashes = toolsArray.map(tool => {
const toolJson = jsonStringify(tool)
const toolHash = shortHash(toolJson)
return {
name: typeof tool.name === 'string' ? tool.name : 'unknown',
hash: toolHash,
json: toolJson,
}
})
// Set span attribute with array of name/hash pairs
span.setAttribute(
'tools',
jsonStringify(
toolsWithHashes.map(({ name, hash }) => ({ name, hash })),
),
)
span.setAttribute('tools_count', toolsWithHashes.length)
// Log each tool's full description once per unique hash
for (const { name, hash, json } of toolsWithHashes) {
if (!seenHashes.has(`tool_${hash}`)) {
seenHashes.add(`tool_${hash}`)
const { content: truncatedTool, truncated } = truncateContent(json)
void logOTelEvent('tool', {
tool_name: sanitizeToolNameForAnalytics(name),
tool_hash: hash,
tool: truncatedTool,
...(truncated && { tool_truncated: 'true' }),
})
}
}
} catch {
// If parsing fails, log the raw tools string
span.setAttribute('tools_parse_error', true)
}
}
// Add new_context using hash-based tracking (visible to all users)
if (messagesForAPI && messagesForAPI.length > 0 && newContext?.querySource) {
const querySource = newContext.querySource
const lastHash = lastReportedMessageHash.get(querySource)
// Find where the last reported message is in the array
let startIndex = 0
if (lastHash) {
for (let i = 0; i < messagesForAPI.length; i++) {
const msg = messagesForAPI[i]
if (msg && hashMessage(msg) === lastHash) {
startIndex = i + 1 // Start after the last reported message
break
}
}
// If lastHash not found, startIndex stays 0 (send everything)
}
// Get new messages (filter out assistant messages - we only want user input/tool results)
const newMessages = messagesForAPI
.slice(startIndex)
.filter((m): m is UserMessage => m.type === 'user')
if (newMessages.length > 0) {
// Format new messages, separating system reminders from regular content
const { contextParts, systemReminders } =
formatMessagesForContext(newMessages)
// Set new_context (regular user content and tool results)
if (contextParts.length > 0) {
const fullContext = contextParts.join('\n\n---\n\n')
const { content: truncatedContext, truncated } =
truncateContent(fullContext)
span.setAttributes({
new_context: truncatedContext,
new_context_message_count: newMessages.length,
...(truncated && {
new_context_truncated: true,
new_context_original_length: fullContext.length,
}),
})
}
// Set system_reminders as a separate attribute
if (systemReminders.length > 0) {
const fullReminders = systemReminders.join('\n\n---\n\n')
const { content: truncatedReminders, truncated: remindersTruncated } =
truncateContent(fullReminders)
span.setAttributes({
system_reminders: truncatedReminders,
system_reminders_count: systemReminders.length,
...(remindersTruncated && {
system_reminders_truncated: true,
system_reminders_original_length: fullReminders.length,
}),
})
}
// Update last reported hash to the last message in the array
const lastMessage = messagesForAPI[messagesForAPI.length - 1]
if (lastMessage) {
lastReportedMessageHash.set(querySource, hashMessage(lastMessage))
}
}
}
}
/**
* Add beta attributes to endLLMRequestSpan.
* Handles model_output and thinking_output truncation.
*/
export function addBetaLLMResponseAttributes(
endAttributes: Record<string, string | number | boolean>,
metadata?: {
_endAttributes: Record<string, string | number | boolean>,
_metadata?: {
modelOutput?: string
thinkingOutput?: string
},
): void {
if (!isBetaTracingEnabled() || !metadata) {
return
}
): void {}
// Add model_output (text content) - visible to all users
if (metadata.modelOutput !== undefined) {
const { content: modelOutput, truncated: outputTruncated } =
truncateContent(metadata.modelOutput)
endAttributes['response.model_output'] = modelOutput
if (outputTruncated) {
endAttributes['response.model_output_truncated'] = true
endAttributes['response.model_output_original_length'] =
metadata.modelOutput.length
}
}
// Add thinking_output - ant-only
if (
process.env.USER_TYPE === 'ant' &&
metadata.thinkingOutput !== undefined
) {
const { content: thinkingOutput, truncated: thinkingTruncated } =
truncateContent(metadata.thinkingOutput)
endAttributes['response.thinking_output'] = thinkingOutput
if (thinkingTruncated) {
endAttributes['response.thinking_output_truncated'] = true
endAttributes['response.thinking_output_original_length'] =
metadata.thinkingOutput.length
}
}
}
/**
* Add beta attributes to startToolSpan.
* Adds tool_input with the serialized tool input.
*/
export function addBetaToolInputAttributes(
span: Span,
toolName: string,
toolInput: string,
): void {
if (!isBetaTracingEnabled()) {
return
}
_span: Span,
_toolName: string,
_toolInput: string,
) {}
const { content: truncatedInput, truncated } = truncateContent(
`[TOOL INPUT: ${toolName}]\n${toolInput}`,
)
span.setAttributes({
tool_input: truncatedInput,
...(truncated && {
tool_input_truncated: true,
tool_input_original_length: toolInput.length,
}),
})
}
/**
* Add beta attributes to endToolSpan.
* Adds new_context with the tool result.
*/
export function addBetaToolResultAttributes(
endAttributes: Record<string, string | number | boolean>,
toolName: string | number | boolean,
toolResult: string,
): void {
if (!isBetaTracingEnabled()) {
return
}
const { content: truncatedResult, truncated } = truncateContent(
`[TOOL RESULT: ${toolName}]\n${toolResult}`,
)
endAttributes['new_context'] = truncatedResult
if (truncated) {
endAttributes['new_context_truncated'] = true
endAttributes['new_context_original_length'] = toolResult.length
}
}
_endAttributes: Record<string, string | number | boolean>,
_toolName: string | number | boolean,
_toolResult: string,
): void {}
+5 -68
View File
@@ -1,17 +1,5 @@
import type { Attributes } from '@opentelemetry/api'
import { getEventLogger, getPromptId } from 'src/bootstrap/state.js'
import { logForDebugging } from '../debug.js'
import { isEnvTruthy } from '../envUtils.js'
import { getTelemetryAttributes } from '../telemetryAttributes.js'
// Monotonically increasing counter for ordering events within a session
let eventSequence = 0
// Track whether we've already warned about a null event logger to avoid spamming
let hasWarnedNoEventLogger = false
function isUserPromptLoggingEnabled() {
return isEnvTruthy(process.env.OTEL_LOG_USER_PROMPTS)
function isUserPromptLoggingEnabled(): boolean {
return false
}
export function redactIfDisabled(content: string): string {
@@ -19,57 +7,6 @@ export function redactIfDisabled(content: string): string {
}
export async function logOTelEvent(
eventName: string,
metadata: { [key: string]: string | undefined } = {},
): Promise<void> {
const eventLogger = getEventLogger()
if (!eventLogger) {
if (!hasWarnedNoEventLogger) {
hasWarnedNoEventLogger = true
logForDebugging(
`[3P telemetry] Event dropped (no event logger initialized): ${eventName}`,
{ level: 'warn' },
)
}
return
}
// Skip logging in test environment
if (process.env.NODE_ENV === 'test') {
return
}
const attributes: Attributes = {
...getTelemetryAttributes(),
'event.name': eventName,
'event.timestamp': new Date().toISOString(),
'event.sequence': eventSequence++,
}
// Add prompt ID to events (but not metrics, where it would cause unbounded cardinality)
const promptId = getPromptId()
if (promptId) {
attributes['prompt.id'] = promptId
}
// Workspace directory from the desktop app (host path). Events only —
// filesystem paths are too high-cardinality for metric dimensions, and
// the BQ metrics pipeline must never see them.
const workspaceDir = process.env.CLAUDE_CODE_WORKSPACE_HOST_PATHS
if (workspaceDir) {
attributes['workspace.host_paths'] = workspaceDir.split('|')
}
// Add metadata as attributes - all values are already strings
for (const [key, value] of Object.entries(metadata)) {
if (value !== undefined) {
attributes[key] = value
}
}
// Emit log record as an event
eventLogger.emit({
body: `claude_code.${eventName}`,
attributes,
})
}
_eventName: string,
_metadata: { [key: string]: string | undefined } = {},
): Promise<void> {}
+11 -812
View File
@@ -1,825 +1,24 @@
import { DiagLogLevel, diag, trace } from '@opentelemetry/api'
import { logs } from '@opentelemetry/api-logs'
// OTLP/Prometheus exporters are dynamically imported inside the protocol
// switch statements below. A process uses at most one protocol variant per
// signal, but static imports would load all 6 (~1.2MB) on every startup.
import {
envDetector,
hostDetector,
osDetector,
resourceFromAttributes,
} from '@opentelemetry/resources'
import {
BatchLogRecordProcessor,
ConsoleLogRecordExporter,
LoggerProvider,
} from '@opentelemetry/sdk-logs'
import {
ConsoleMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from '@opentelemetry/sdk-metrics'
import {
BasicTracerProvider,
BatchSpanProcessor,
ConsoleSpanExporter,
} from '@opentelemetry/sdk-trace-base'
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
SEMRESATTRS_HOST_ARCH,
} from '@opentelemetry/semantic-conventions'
import { HttpsProxyAgent } from 'https-proxy-agent'
import {
getLoggerProvider,
getMeterProvider,
getTracerProvider,
setEventLogger,
setLoggerProvider,
setMeterProvider,
setTracerProvider,
} from 'src/bootstrap/state.js'
import {
getOtelHeadersFromHelper,
getSubscriptionType,
is1PApiCustomer,
isClaudeAISubscriber,
} from 'src/utils/auth.js'
import { getPlatform, getWslVersion } from 'src/utils/platform.js'
/**
* Telemetry instrumentation is disabled in the OSS build.
*/
import { getCACertificates } from '../caCerts.js'
import { registerCleanup } from '../cleanupRegistry.js'
import { getHasFormattedOutput, logForDebugging } from '../debug.js'
import { isEnvTruthy } from '../envUtils.js'
import { errorMessage } from '../errors.js'
import { getMTLSConfig } from '../mtls.js'
import { getProxyUrl, shouldBypassProxy } from '../proxy.js'
import { getSettings_DEPRECATED } from '../settings/settings.js'
import { jsonStringify } from '../slowOperations.js'
import { profileCheckpoint } from '../startupProfiler.js'
import { isBetaTracingEnabled } from './betaSessionTracing.js'
import { BigQueryMetricsExporter } from './bigqueryExporter.js'
import { ClaudeCodeDiagLogger } from './logger.js'
import { initializePerfettoTracing } from './perfettoTracing.js'
import {
endInteractionSpan,
isEnhancedTelemetryEnabled,
} from './sessionTracing.js'
export function bootstrapTelemetry(): void {}
const DEFAULT_METRICS_EXPORT_INTERVAL_MS = 60000
const DEFAULT_LOGS_EXPORT_INTERVAL_MS = 5000
const DEFAULT_TRACES_EXPORT_INTERVAL_MS = 5000
class TelemetryTimeoutError extends Error {}
function telemetryTimeout(ms: number, message: string): Promise<never> {
return new Promise((_, reject) => {
setTimeout(
(rej: (e: Error) => void, msg: string) =>
rej(new TelemetryTimeoutError(msg)),
ms,
reject,
message,
).unref()
})
}
export function bootstrapTelemetry() {
if (process.env.USER_TYPE === 'ant') {
// Read from ANT_ prefixed variables that are defined at build time
if (process.env.ANT_OTEL_METRICS_EXPORTER) {
process.env.OTEL_METRICS_EXPORTER = process.env.ANT_OTEL_METRICS_EXPORTER
}
if (process.env.ANT_OTEL_LOGS_EXPORTER) {
process.env.OTEL_LOGS_EXPORTER = process.env.ANT_OTEL_LOGS_EXPORTER
}
if (process.env.ANT_OTEL_TRACES_EXPORTER) {
process.env.OTEL_TRACES_EXPORTER = process.env.ANT_OTEL_TRACES_EXPORTER
}
if (process.env.ANT_OTEL_EXPORTER_OTLP_PROTOCOL) {
process.env.OTEL_EXPORTER_OTLP_PROTOCOL =
process.env.ANT_OTEL_EXPORTER_OTLP_PROTOCOL
}
if (process.env.ANT_OTEL_EXPORTER_OTLP_ENDPOINT) {
process.env.OTEL_EXPORTER_OTLP_ENDPOINT =
process.env.ANT_OTEL_EXPORTER_OTLP_ENDPOINT
}
if (process.env.ANT_OTEL_EXPORTER_OTLP_HEADERS) {
process.env.OTEL_EXPORTER_OTLP_HEADERS =
process.env.ANT_OTEL_EXPORTER_OTLP_HEADERS
}
}
// Set default tempoality to 'delta' because it's the more sane default
if (!process.env.OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE) {
process.env.OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = 'delta'
}
}
// Per OTEL spec, "none" means "no automatically configured exporter for this signal".
// https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#exporter-selection
export function parseExporterTypes(value: string | undefined): string[] {
return (value || '')
.trim()
.split(',')
.filter(Boolean)
.map(t => t.trim())
.filter(t => t !== 'none')
.map(token => token.trim())
.filter(token => token !== 'none')
}
async function getOtlpReaders() {
const exporterTypes = parseExporterTypes(process.env.OTEL_METRICS_EXPORTER)
const exportInterval = parseInt(
process.env.OTEL_METRIC_EXPORT_INTERVAL ||
DEFAULT_METRICS_EXPORT_INTERVAL_MS.toString(),
)
const exporters = []
for (const exporterType of exporterTypes) {
if (exporterType === 'console') {
// Custom console exporter that shows resource attributes
const consoleExporter = new ConsoleMetricExporter()
const originalExport = consoleExporter.export.bind(consoleExporter)
consoleExporter.export = (metrics, callback) => {
// Log resource attributes once at the start
if (metrics.resource && metrics.resource.attributes) {
// The console exporter is for debugging, so console output is intentional here
logForDebugging('\n=== Resource Attributes ===')
logForDebugging(jsonStringify(metrics.resource.attributes))
logForDebugging('===========================\n')
}
return originalExport(metrics, callback)
}
exporters.push(consoleExporter)
} else if (exporterType === 'otlp') {
const protocol =
process.env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL?.trim() ||
process.env.OTEL_EXPORTER_OTLP_PROTOCOL?.trim()
const httpConfig = getOTLPExporterConfig()
switch (protocol) {
case 'grpc': {
// Lazy-import to keep @grpc/grpc-js (~700KB) out of the telemetry chunk
// when the protocol is http/protobuf (ant default) or http/json.
const { OTLPMetricExporter } = await import(
'@opentelemetry/exporter-metrics-otlp-grpc'
)
exporters.push(new OTLPMetricExporter())
break
}
case 'http/json': {
const { OTLPMetricExporter } = await import(
'@opentelemetry/exporter-metrics-otlp-http'
)
exporters.push(new OTLPMetricExporter(httpConfig))
break
}
case 'http/protobuf': {
const { OTLPMetricExporter } = await import(
'@opentelemetry/exporter-metrics-otlp-proto'
)
exporters.push(new OTLPMetricExporter(httpConfig))
break
}
default:
throw new Error(
`Unknown protocol set in OTEL_EXPORTER_OTLP_METRICS_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: ${protocol}`,
)
}
} else if (exporterType === 'prometheus') {
const { PrometheusExporter } = await import(
'@opentelemetry/exporter-prometheus'
)
exporters.push(new PrometheusExporter())
} else {
throw new Error(
`Unknown exporter type set in OTEL_EXPORTER_OTLP_METRICS_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: ${exporterType}`,
)
}
}
return exporters.map(exporter => {
if ('export' in exporter) {
return new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: exportInterval,
})
}
return exporter
})
export function isTelemetryEnabled(): boolean {
return false
}
async function getOtlpLogExporters() {
const exporterTypes = parseExporterTypes(process.env.OTEL_LOGS_EXPORTER)
const protocol =
process.env.OTEL_EXPORTER_OTLP_LOGS_PROTOCOL?.trim() ||
process.env.OTEL_EXPORTER_OTLP_PROTOCOL?.trim()
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
logForDebugging(
`[3P telemetry] getOtlpLogExporters: types=${jsonStringify(exporterTypes)}, protocol=${protocol}, endpoint=${endpoint}`,
)
const exporters = []
for (const exporterType of exporterTypes) {
if (exporterType === 'console') {
exporters.push(new ConsoleLogRecordExporter())
} else if (exporterType === 'otlp') {
const httpConfig = getOTLPExporterConfig()
switch (protocol) {
case 'grpc': {
const { OTLPLogExporter } = await import(
'@opentelemetry/exporter-logs-otlp-grpc'
)
exporters.push(new OTLPLogExporter())
break
}
case 'http/json': {
const { OTLPLogExporter } = await import(
'@opentelemetry/exporter-logs-otlp-http'
)
exporters.push(new OTLPLogExporter(httpConfig))
break
}
case 'http/protobuf': {
const { OTLPLogExporter } = await import(
'@opentelemetry/exporter-logs-otlp-proto'
)
exporters.push(new OTLPLogExporter(httpConfig))
break
}
default:
throw new Error(
`Unknown protocol set in OTEL_EXPORTER_OTLP_LOGS_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: ${protocol}`,
)
}
} else {
throw new Error(
`Unknown exporter type set in OTEL_LOGS_EXPORTER env var: ${exporterType}`,
)
}
}
return exporters
export async function initializeTelemetry(): Promise<null> {
return null
}
async function getOtlpTraceExporters() {
const exporterTypes = parseExporterTypes(process.env.OTEL_TRACES_EXPORTER)
const exporters = []
for (const exporterType of exporterTypes) {
if (exporterType === 'console') {
exporters.push(new ConsoleSpanExporter())
} else if (exporterType === 'otlp') {
const protocol =
process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL?.trim() ||
process.env.OTEL_EXPORTER_OTLP_PROTOCOL?.trim()
const httpConfig = getOTLPExporterConfig()
switch (protocol) {
case 'grpc': {
const { OTLPTraceExporter } = await import(
'@opentelemetry/exporter-trace-otlp-grpc'
)
exporters.push(new OTLPTraceExporter())
break
}
case 'http/json': {
const { OTLPTraceExporter } = await import(
'@opentelemetry/exporter-trace-otlp-http'
)
exporters.push(new OTLPTraceExporter(httpConfig))
break
}
case 'http/protobuf': {
const { OTLPTraceExporter } = await import(
'@opentelemetry/exporter-trace-otlp-proto'
)
exporters.push(new OTLPTraceExporter(httpConfig))
break
}
default:
throw new Error(
`Unknown protocol set in OTEL_EXPORTER_OTLP_TRACES_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: ${protocol}`,
)
}
} else {
throw new Error(
`Unknown exporter type set in OTEL_TRACES_EXPORTER env var: ${exporterType}`,
)
}
}
return exporters
}
export function isTelemetryEnabled() {
return isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_TELEMETRY)
}
function getBigQueryExportingReader() {
const bigqueryExporter = new BigQueryMetricsExporter()
return new PeriodicExportingMetricReader({
exporter: bigqueryExporter,
exportIntervalMillis: 5 * 60 * 1000, // 5mins for BigQuery metrics exporter to reduce load
})
}
function isBigQueryMetricsEnabled() {
// BigQuery metrics are enabled for:
// 1. API customers (excluding Claude.ai subscribers and Bedrock/Vertex)
// 2. Claude for Enterprise (C4E) users
// 3. Claude for Teams users
const subscriptionType = getSubscriptionType()
const isC4EOrTeamUser =
isClaudeAISubscriber() &&
(subscriptionType === 'enterprise' || subscriptionType === 'team')
return is1PApiCustomer() || isC4EOrTeamUser
}
/**
* Initialize beta tracing - a separate code path for detailed debugging.
* Uses BETA_TRACING_ENDPOINT instead of OTEL_EXPORTER_OTLP_ENDPOINT.
*/
async function initializeBetaTracing(
resource: ReturnType<typeof resourceFromAttributes>,
): Promise<void> {
const endpoint = process.env.BETA_TRACING_ENDPOINT
if (!endpoint) {
return
}
const [{ OTLPTraceExporter }, { OTLPLogExporter }] = await Promise.all([
import('@opentelemetry/exporter-trace-otlp-http'),
import('@opentelemetry/exporter-logs-otlp-http'),
])
const httpConfig = {
url: `${endpoint}/v1/traces`,
}
const logHttpConfig = {
url: `${endpoint}/v1/logs`,
}
// Initialize trace exporter
const traceExporter = new OTLPTraceExporter(httpConfig)
const spanProcessor = new BatchSpanProcessor(traceExporter, {
scheduledDelayMillis: DEFAULT_TRACES_EXPORT_INTERVAL_MS,
})
const tracerProvider = new BasicTracerProvider({
resource,
spanProcessors: [spanProcessor],
})
trace.setGlobalTracerProvider(tracerProvider)
setTracerProvider(tracerProvider)
// Initialize log exporter
const logExporter = new OTLPLogExporter(logHttpConfig)
const loggerProvider = new LoggerProvider({
resource,
processors: [
new BatchLogRecordProcessor(logExporter, {
scheduledDelayMillis: DEFAULT_LOGS_EXPORT_INTERVAL_MS,
}),
],
})
logs.setGlobalLoggerProvider(loggerProvider)
setLoggerProvider(loggerProvider)
// Initialize event logger
const eventLogger = logs.getLogger(
'com.anthropic.claude_code.events',
MACRO.VERSION,
)
setEventLogger(eventLogger)
// Setup flush handlers - flush both logs AND traces
process.on('beforeExit', async () => {
await loggerProvider?.forceFlush()
await tracerProvider?.forceFlush()
})
process.on('exit', () => {
void loggerProvider?.forceFlush()
void tracerProvider?.forceFlush()
})
}
export async function initializeTelemetry() {
profileCheckpoint('telemetry_init_start')
bootstrapTelemetry()
// Console exporters call console.dir on a timer (5s logs/traces, 60s
// metrics), writing pretty-printed objects to stdout. In stream-json
// mode stdout is the SDK message channel; the first line (`{`) breaks
// the SDK's line reader. Stripped here (not main.tsx) because init.ts
// re-runs applyConfigEnvironmentVariables() inside initializeTelemetry-
// AfterTrust for remote-managed-settings users, and bootstrapTelemetry
// above copies ANT_OTEL_* for ant users — both would undo an earlier strip.
if (getHasFormattedOutput()) {
for (const key of [
'OTEL_METRICS_EXPORTER',
'OTEL_LOGS_EXPORTER',
'OTEL_TRACES_EXPORTER',
] as const) {
const v = process.env[key]
if (v?.includes('console')) {
process.env[key] = v
.split(',')
.map(s => s.trim())
.filter(s => s !== 'console')
.join(',')
}
}
}
diag.setLogger(new ClaudeCodeDiagLogger(), DiagLogLevel.ERROR)
// Initialize Perfetto tracing (independent of OTEL)
// Enable via CLAUDE_CODE_PERFETTO_TRACE=1 or CLAUDE_CODE_PERFETTO_TRACE=<path>
initializePerfettoTracing()
const readers = []
// Add customer exporters (if enabled)
const telemetryEnabled = isTelemetryEnabled()
logForDebugging(
`[3P telemetry] isTelemetryEnabled=${telemetryEnabled} (CLAUDE_CODE_ENABLE_TELEMETRY=${process.env.CLAUDE_CODE_ENABLE_TELEMETRY})`,
)
if (telemetryEnabled) {
readers.push(...(await getOtlpReaders()))
}
// Add BigQuery exporter (for API customers, C4E users, and internal users)
if (isBigQueryMetricsEnabled()) {
readers.push(getBigQueryExportingReader())
}
// Create base resource with service attributes
const platform = getPlatform()
const baseAttributes: Record<string, string> = {
[ATTR_SERVICE_NAME]: 'claude-code',
[ATTR_SERVICE_VERSION]: MACRO.VERSION,
}
// Add WSL-specific attributes if running on WSL
if (platform === 'wsl') {
const wslVersion = getWslVersion()
if (wslVersion) {
baseAttributes['wsl.version'] = wslVersion
}
}
const baseResource = resourceFromAttributes(baseAttributes)
// Use OpenTelemetry detectors
const osResource = resourceFromAttributes(
osDetector.detect().attributes || {},
)
// Extract only host.arch from hostDetector
const hostDetected = hostDetector.detect()
const hostArchAttributes = hostDetected.attributes?.[SEMRESATTRS_HOST_ARCH]
? {
[SEMRESATTRS_HOST_ARCH]: hostDetected.attributes[SEMRESATTRS_HOST_ARCH],
}
: {}
const hostArchResource = resourceFromAttributes(hostArchAttributes)
const envResource = resourceFromAttributes(
envDetector.detect().attributes || {},
)
// Merge resources - later resources take precedence
const resource = baseResource
.merge(osResource)
.merge(hostArchResource)
.merge(envResource)
// Check if beta tracing is enabled - this is a separate code path
// Available to all users who set ENABLE_BETA_TRACING_DETAILED=1 and BETA_TRACING_ENDPOINT
if (isBetaTracingEnabled()) {
void initializeBetaTracing(resource).catch(e =>
logForDebugging(`Beta tracing init failed: ${e}`, { level: 'error' }),
)
// Still set up meter provider for metrics (but skip regular logs/traces setup)
const meterProvider = new MeterProvider({
resource,
views: [],
readers,
})
setMeterProvider(meterProvider)
// Register shutdown for beta tracing
const shutdownTelemetry = async () => {
const timeoutMs = parseInt(
process.env.CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS || '2000',
)
try {
endInteractionSpan()
// Force flush + shutdown together inside the timeout. Previously forceFlush
// was awaited unbounded BEFORE the race, blocking exit on slow OTLP endpoints.
// Each provider's flush→shutdown is chained independently so a slow logger
// flush doesn't delay meterProvider/tracerProvider shutdown (no waterfall).
const loggerProvider = getLoggerProvider()
const tracerProvider = getTracerProvider()
const chains: Promise<void>[] = [meterProvider.shutdown()]
if (loggerProvider) {
chains.push(
loggerProvider.forceFlush().then(() => loggerProvider.shutdown()),
)
}
if (tracerProvider) {
chains.push(
tracerProvider.forceFlush().then(() => tracerProvider.shutdown()),
)
}
await Promise.race([
Promise.all(chains),
telemetryTimeout(timeoutMs, 'OpenTelemetry shutdown timeout'),
])
} catch {
// Ignore shutdown errors
}
}
registerCleanup(shutdownTelemetry)
return meterProvider.getMeter('com.anthropic.claude_code', MACRO.VERSION)
}
const meterProvider = new MeterProvider({
resource,
views: [],
readers,
})
// Store reference in state for flushing
setMeterProvider(meterProvider)
// Initialize logs if telemetry is enabled
if (telemetryEnabled) {
const logExporters = await getOtlpLogExporters()
logForDebugging(
`[3P telemetry] Created ${logExporters.length} log exporter(s)`,
)
if (logExporters.length > 0) {
const loggerProvider = new LoggerProvider({
resource,
// Add batch processors for each exporter
processors: logExporters.map(
exporter =>
new BatchLogRecordProcessor(exporter, {
scheduledDelayMillis: parseInt(
process.env.OTEL_LOGS_EXPORT_INTERVAL ||
DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(),
),
}),
),
})
// Register the logger provider globally
logs.setGlobalLoggerProvider(loggerProvider)
setLoggerProvider(loggerProvider)
// Initialize event logger
const eventLogger = logs.getLogger(
'com.anthropic.claude_code.events',
MACRO.VERSION,
)
setEventLogger(eventLogger)
logForDebugging('[3P telemetry] Event logger set successfully')
// 'beforeExit' is emitted when Node.js empties its event loop and has no additional work to schedule.
// Unlike 'exit', it allows us to perform async operations, so it works well for letting
// network requests complete before the process exits naturally.
process.on('beforeExit', async () => {
await loggerProvider?.forceFlush()
// Also flush traces - they use BatchSpanProcessor which needs explicit flush
const tracerProvider = getTracerProvider()
await tracerProvider?.forceFlush()
})
process.on('exit', () => {
// Final attempt to flush logs and traces
void loggerProvider?.forceFlush()
void getTracerProvider()?.forceFlush()
})
}
}
// Initialize tracing if enhanced telemetry is enabled (BETA)
if (telemetryEnabled && isEnhancedTelemetryEnabled()) {
const traceExporters = await getOtlpTraceExporters()
if (traceExporters.length > 0) {
// Create span processors for each exporter
const spanProcessors = traceExporters.map(
exporter =>
new BatchSpanProcessor(exporter, {
scheduledDelayMillis: parseInt(
process.env.OTEL_TRACES_EXPORT_INTERVAL ||
DEFAULT_TRACES_EXPORT_INTERVAL_MS.toString(),
),
}),
)
const tracerProvider = new BasicTracerProvider({
resource,
spanProcessors,
})
// Register the tracer provider globally
trace.setGlobalTracerProvider(tracerProvider)
setTracerProvider(tracerProvider)
}
}
// Shutdown metrics and logs on exit (flushes and closes exporters)
const shutdownTelemetry = async () => {
const timeoutMs = parseInt(
process.env.CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS || '2000',
)
try {
// End any active interaction span before shutdown
endInteractionSpan()
const shutdownPromises = [meterProvider.shutdown()]
const loggerProvider = getLoggerProvider()
if (loggerProvider) {
shutdownPromises.push(loggerProvider.shutdown())
}
const tracerProvider = getTracerProvider()
if (tracerProvider) {
shutdownPromises.push(tracerProvider.shutdown())
}
await Promise.race([
Promise.all(shutdownPromises),
telemetryTimeout(timeoutMs, 'OpenTelemetry shutdown timeout'),
])
} catch (error) {
if (error instanceof Error && error.message.includes('timeout')) {
logForDebugging(
`
OpenTelemetry telemetry flush timed out after ${timeoutMs}ms
To resolve this issue, you can:
1. Increase the timeout by setting CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS env var (e.g., 5000 for 5 seconds)
2. Check if your OpenTelemetry backend is experiencing scalability issues
3. Disable OpenTelemetry by unsetting CLAUDE_CODE_ENABLE_TELEMETRY env var
Current timeout: ${timeoutMs}ms
`,
{ level: 'error' },
)
}
throw error
}
}
// Always register shutdown (internal metrics are always enabled)
registerCleanup(shutdownTelemetry)
return meterProvider.getMeter('com.anthropic.claude_code', MACRO.VERSION)
}
/**
* Flush all pending telemetry data immediately.
* This should be called before logout or org switching to prevent data leakage.
*/
export async function flushTelemetry(): Promise<void> {
const meterProvider = getMeterProvider()
if (!meterProvider) {
return
}
const timeoutMs = parseInt(
process.env.CLAUDE_CODE_OTEL_FLUSH_TIMEOUT_MS || '5000',
)
try {
const flushPromises = [meterProvider.forceFlush()]
const loggerProvider = getLoggerProvider()
if (loggerProvider) {
flushPromises.push(loggerProvider.forceFlush())
}
const tracerProvider = getTracerProvider()
if (tracerProvider) {
flushPromises.push(tracerProvider.forceFlush())
}
await Promise.race([
Promise.all(flushPromises),
telemetryTimeout(timeoutMs, 'OpenTelemetry flush timeout'),
])
logForDebugging('Telemetry flushed successfully')
} catch (error) {
if (error instanceof TelemetryTimeoutError) {
logForDebugging(
`Telemetry flush timed out after ${timeoutMs}ms. Some metrics may not be exported.`,
{ level: 'warn' },
)
} else {
logForDebugging(`Telemetry flush failed: ${errorMessage(error)}`, {
level: 'error',
})
}
// Don't throw - allow logout to continue even if flush fails
}
}
function parseOtelHeadersEnvVar(): Record<string, string> {
const headers: Record<string, string> = {}
const envHeaders = process.env.OTEL_EXPORTER_OTLP_HEADERS
if (envHeaders) {
for (const pair of envHeaders.split(',')) {
const [key, ...valueParts] = pair.split('=')
if (key && valueParts.length > 0) {
headers[key.trim()] = valueParts.join('=').trim()
}
}
}
return headers
}
/**
* Get configuration for OTLP exporters including:
* - HTTP agent options (proxy, mTLS)
* - Dynamic headers via otelHeadersHelper or static headers from env var
*/
function getOTLPExporterConfig() {
const proxyUrl = getProxyUrl()
const mtlsConfig = getMTLSConfig()
const settings = getSettings_DEPRECATED()
// Build base config
const config: Record<string, unknown> = {}
// Parse static headers from env var once (doesn't change at runtime)
const staticHeaders = parseOtelHeadersEnvVar()
// If otelHeadersHelper is configured, use async headers function for dynamic refresh
// Otherwise just return static headers if any exist
if (settings?.otelHeadersHelper) {
config.headers = async (): Promise<Record<string, string>> => {
const dynamicHeaders = getOtelHeadersFromHelper()
return { ...staticHeaders, ...dynamicHeaders }
}
} else if (Object.keys(staticHeaders).length > 0) {
config.headers = async (): Promise<Record<string, string>> => staticHeaders
}
// Check if we should bypass proxy for OTEL endpoint
const otelEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
if (!proxyUrl || (otelEndpoint && shouldBypassProxy(otelEndpoint))) {
// No proxy configured or OTEL endpoint should bypass proxy
const caCerts = getCACertificates()
if (mtlsConfig || caCerts) {
config.httpAgentOptions = {
...mtlsConfig,
...(caCerts && { ca: caCerts }),
}
}
return config
}
// Return an HttpAgentFactory function that creates our proxy agent
const caCerts = getCACertificates()
const agentFactory = (_protocol: string) => {
// Create and return the proxy agent with mTLS and CA cert config
const proxyAgent =
mtlsConfig || caCerts
? new HttpsProxyAgent(proxyUrl, {
...(mtlsConfig && {
cert: mtlsConfig.cert,
key: mtlsConfig.key,
passphrase: mtlsConfig.passphrase,
}),
...(caCerts && { ca: caCerts }),
})
: new HttpsProxyAgent(proxyUrl)
return proxyAgent
}
config.httpAgentOptions = agentFactory
return config
}
export async function flushTelemetry(): Promise<void> {}
+65 -845
View File
@@ -1,358 +1,62 @@
/**
* Session Tracing for Claude Code using OpenTelemetry (BETA)
*
* This module provides a high-level API for creating and managing spans
* to trace Claude Code workflows. Each user interaction creates a root
* interaction span, which contains operation spans (LLM requests, tool calls, etc.).
*
* Requirements:
* - Enhanced telemetry is enabled via feature('ENHANCED_TELEMETRY_BETA')
* - Configure OTEL_TRACES_EXPORTER (console, otlp, etc.)
*/
import { feature } from 'bun:bundle'
import { context as otelContext, type Span, trace } from '@opentelemetry/api'
import { AsyncLocalStorage } from 'async_hooks'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import type { AssistantMessage, UserMessage } from '../../types/message.js'
import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js'
import { getTelemetryAttributes } from '../telemetryAttributes.js'
import {
addBetaInteractionAttributes,
addBetaLLMRequestAttributes,
addBetaLLMResponseAttributes,
addBetaToolInputAttributes,
addBetaToolResultAttributes,
isBetaTracingEnabled,
type LLMRequestNewContext,
truncateContent,
} from './betaSessionTracing.js'
import {
endInteractionPerfettoSpan,
endLLMRequestPerfettoSpan,
endToolPerfettoSpan,
endUserInputPerfettoSpan,
isPerfettoTracingEnabled,
startInteractionPerfettoSpan,
startLLMRequestPerfettoSpan,
startToolPerfettoSpan,
startUserInputPerfettoSpan,
} from './perfettoTracing.js'
// Re-export for callers
export type { Span }
export { isBetaTracingEnabled, type LLMRequestNewContext }
// Message type for API calls (UserMessage or AssistantMessage)
type APIMessage = UserMessage | AssistantMessage
type SpanType =
| 'interaction'
| 'llm_request'
| 'tool'
| 'tool.blocked_on_user'
| 'tool.execution'
| 'hook'
interface SpanContext {
span: Span
startTime: number
attributes: Record<string, string | number | boolean>
ended?: boolean
perfettoSpanId?: string
export interface Span {
setAttribute(name: string, value: string | number | boolean): void
setAttributes(attributes: Record<string, string | number | boolean>): void
addEvent(
name: string,
attributes?: Record<string, string | number | boolean>,
): void
end(): void
recordException(error: unknown): void
}
// ALS stores SpanContext directly so it holds a strong reference while a span
// is active. With that, activeSpans can use WeakRef — when ALS is cleared
// (enterWith(undefined)) and no other code holds the SpanContext, GC can collect
// it and the WeakRef goes stale.
const interactionContext = new AsyncLocalStorage<SpanContext | undefined>()
const toolContext = new AsyncLocalStorage<SpanContext | undefined>()
const activeSpans = new Map<string, WeakRef<SpanContext>>()
// Spans not stored in ALS (LLM request, blocked-on-user, tool execution, hook)
// need a strong reference to prevent GC from collecting the SpanContext before
// the corresponding end* function retrieves it.
const strongSpans = new Map<string, SpanContext>()
let interactionSequence = 0
let _cleanupIntervalStarted = false
type SpanMetadata = Record<string, string | number | boolean>
const SPAN_TTL_MS = 30 * 60 * 1000 // 30 minutes
function getSpanId(span: Span): string {
return span.spanContext().spanId || ''
}
/**
* Lazily start a background interval that evicts orphaned spans from activeSpans.
*
* Normal teardown calls endInteractionSpan / endToolSpan, which delete spans
* immediately. This interval is a safety net for spans that were never ended
* (e.g. aborted streams, uncaught exceptions mid-query) — without it they
* accumulate in activeSpans indefinitely, holding references to Span objects
* and the OpenTelemetry context chain.
*
* Initialized on the first startInteractionSpan call (not at module load) to
* avoid triggering the no-top-level-side-effects lint rule and to keep the
* interval from running in processes that never start a span.
* unref() prevents the timer from keeping the process alive after all other
* work is done.
*/
function ensureCleanupInterval(): void {
if (_cleanupIntervalStarted) return
_cleanupIntervalStarted = true
const interval = setInterval(() => {
const cutoff = Date.now() - SPAN_TTL_MS
for (const [spanId, weakRef] of activeSpans) {
const ctx = weakRef.deref()
if (ctx === undefined) {
activeSpans.delete(spanId)
strongSpans.delete(spanId)
} else if (ctx.startTime < cutoff) {
if (!ctx.ended) ctx.span.end() // flush any recorded attributes to the exporter
activeSpans.delete(spanId)
strongSpans.delete(spanId)
}
}
}, 60_000)
if (typeof interval.unref === 'function') {
interval.unref() // Node.js / Bun: don't block process exit
function createNoopSpan(): Span {
return {
setAttribute() {},
setAttributes() {},
addEvent() {},
end() {},
recordException() {},
}
}
/**
* Check if enhanced telemetry is enabled.
* Priority: env var override > ant build > GrowthBook gate
*/
let currentInteractionSpan: Span | null = null
let currentToolSpan: Span | null = null
export function isEnhancedTelemetryEnabled(): boolean {
if (feature('ENHANCED_TELEMETRY_BETA')) {
const env =
process.env.CLAUDE_CODE_ENHANCED_TELEMETRY_BETA ??
process.env.ENABLE_ENHANCED_TELEMETRY_BETA
if (isEnvTruthy(env)) {
return true
}
if (isEnvDefinedFalsy(env)) {
return false
}
return (
process.env.USER_TYPE === 'ant' ||
getFeatureValue_CACHED_MAY_BE_STALE('enhanced_telemetry_beta', false)
)
}
return false
}
/**
* Check if any tracing is enabled (either standard enhanced telemetry OR beta tracing)
*/
function isAnyTracingEnabled(): boolean {
return isEnhancedTelemetryEnabled() || isBetaTracingEnabled()
}
function getTracer() {
return trace.getTracer('com.anthropic.claude_code.tracing', '1.0.0')
}
function createSpanAttributes(
spanType: SpanType,
customAttributes: Record<string, string | number | boolean> = {},
): Record<string, string | number | boolean> {
const baseAttributes = getTelemetryAttributes()
const attributes: Record<string, string | number | boolean> = {
...baseAttributes,
'span.type': spanType,
...customAttributes,
}
return attributes
}
/**
* Start an interaction span. This wraps a user request -> Claude response cycle.
* This is now a root span that includes all session-level attributes.
* Sets the interaction context for all subsequent operations.
*/
export function startInteractionSpan(userPrompt: string): Span {
ensureCleanupInterval()
// Start Perfetto span regardless of OTel tracing state
const perfettoSpanId = isPerfettoTracingEnabled()
? startInteractionPerfettoSpan(userPrompt)
: undefined
if (!isAnyTracingEnabled()) {
// Still track Perfetto span even if OTel is disabled
if (perfettoSpanId) {
const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy')
const spanId = getSpanId(dummySpan)
const spanContextObj: SpanContext = {
span: dummySpan,
startTime: Date.now(),
attributes: {},
perfettoSpanId,
}
activeSpans.set(spanId, new WeakRef(spanContextObj))
interactionContext.enterWith(spanContextObj)
return dummySpan
}
return trace.getActiveSpan() || getTracer().startSpan('dummy')
}
const tracer = getTracer()
const isUserPromptLoggingEnabled = isEnvTruthy(
process.env.OTEL_LOG_USER_PROMPTS,
)
const promptToLog = isUserPromptLoggingEnabled ? userPrompt : '<REDACTED>'
interactionSequence++
const attributes = createSpanAttributes('interaction', {
user_prompt: promptToLog,
user_prompt_length: userPrompt.length,
'interaction.sequence': interactionSequence,
})
const span = tracer.startSpan('claude_code.interaction', {
attributes,
})
// Add experimental attributes (new_context)
addBetaInteractionAttributes(span, userPrompt)
const spanId = getSpanId(span)
const spanContextObj: SpanContext = {
span,
startTime: Date.now(),
attributes,
perfettoSpanId,
}
activeSpans.set(spanId, new WeakRef(spanContextObj))
interactionContext.enterWith(spanContextObj)
export function startInteractionSpan(_userPrompt: string): Span {
const span = createNoopSpan()
currentInteractionSpan = span
return span
}
export function endInteractionSpan(): void {
const spanContext = interactionContext.getStore()
if (!spanContext) {
return
}
if (spanContext.ended) {
return
}
// End Perfetto span
if (spanContext.perfettoSpanId) {
endInteractionPerfettoSpan(spanContext.perfettoSpanId)
}
if (!isAnyTracingEnabled()) {
spanContext.ended = true
activeSpans.delete(getSpanId(spanContext.span))
// Clear the store so async continuations created after this point (timers,
// promise callbacks, I/O) do not inherit a reference to the ended span.
// enterWith(undefined) is intentional: exit(() => {}) is a no-op because it
// only suppresses the store inside the callback and returns immediately.
interactionContext.enterWith(undefined)
return
}
const duration = Date.now() - spanContext.startTime
spanContext.span.setAttributes({
'interaction.duration_ms': duration,
})
spanContext.span.end()
spanContext.ended = true
activeSpans.delete(getSpanId(spanContext.span))
interactionContext.enterWith(undefined)
currentInteractionSpan = null
}
export function startLLMRequestSpan(
model: string,
newContext?: LLMRequestNewContext,
messagesForAPI?: APIMessage[],
fastMode?: boolean,
_model: string,
_newContext?: LLMRequestNewContext,
_messagesForAPI?: unknown[],
_fastMode?: boolean,
): Span {
// Start Perfetto span regardless of OTel tracing state
const perfettoSpanId = isPerfettoTracingEnabled()
? startLLMRequestPerfettoSpan({
model,
querySource: newContext?.querySource,
messageId: undefined, // Will be set in endLLMRequestSpan
})
: undefined
if (!isAnyTracingEnabled()) {
// Still track Perfetto span even if OTel is disabled
if (perfettoSpanId) {
const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy')
const spanId = getSpanId(dummySpan)
const spanContextObj: SpanContext = {
span: dummySpan,
startTime: Date.now(),
attributes: { model },
perfettoSpanId,
}
activeSpans.set(spanId, new WeakRef(spanContextObj))
strongSpans.set(spanId, spanContextObj)
return dummySpan
}
return trace.getActiveSpan() || getTracer().startSpan('dummy')
}
const tracer = getTracer()
const parentSpanCtx = interactionContext.getStore()
const attributes = createSpanAttributes('llm_request', {
model: model,
'llm_request.context': parentSpanCtx ? 'interaction' : 'standalone',
speed: fastMode ? 'fast' : 'normal',
})
const ctx = parentSpanCtx
? trace.setSpan(otelContext.active(), parentSpanCtx.span)
: otelContext.active()
const span = tracer.startSpan('claude_code.llm_request', { attributes }, ctx)
// Add query_source (agent name) if provided
if (newContext?.querySource) {
span.setAttribute('query_source', newContext.querySource)
}
// Add experimental attributes (system prompt, new_context)
addBetaLLMRequestAttributes(span, newContext, messagesForAPI)
const spanId = getSpanId(span)
const spanContextObj: SpanContext = {
span,
startTime: Date.now(),
attributes,
perfettoSpanId,
}
activeSpans.set(spanId, new WeakRef(spanContextObj))
strongSpans.set(spanId, spanContextObj)
return span
return createNoopSpan()
}
/**
* End an LLM request span and attach response metadata.
*
* @param span - Optional. The exact span returned by startLLMRequestSpan().
* 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).
*/
export function endLLMRequestSpan(
span?: Span,
metadata?: {
_span?: Span,
_metadata?: {
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
@@ -362,566 +66,82 @@ export function endLLMRequestSpan(
error?: string
attempt?: number
modelResponse?: string
/** Text output from the model (non-thinking content) */
modelOutput?: string
/** Thinking/reasoning output from the model */
thinkingOutput?: string
/** Whether the output included tool calls (look at tool spans for details) */
hasToolCall?: boolean
/** Time to first token in milliseconds */
ttftMs?: number
/** Time spent in pre-request setup before the successful attempt */
requestSetupMs?: number
/** Timestamps (Date.now()) of each attempt start — used to emit retry sub-spans */
attemptStartTimes?: number[]
},
): void {
let llmSpanContext: SpanContext | undefined
if (span) {
// Use the provided span directly - this is the correct approach for parallel requests
const spanId = getSpanId(span)
llmSpanContext = activeSpans.get(spanId)?.deref()
} else {
// Legacy fallback: find the most recent llm_request span
// WARNING: This can cause mismatched responses when multiple requests are in flight
llmSpanContext = Array.from(activeSpans.values())
.findLast(r => {
const ctx = r.deref()
return (
ctx?.attributes['span.type'] === 'llm_request' ||
ctx?.attributes['model']
)
})
?.deref()
}
if (!llmSpanContext) {
// Span was already ended or never tracked
return
}
const duration = Date.now() - llmSpanContext.startTime
// End Perfetto span with full metadata
if (llmSpanContext.perfettoSpanId) {
endLLMRequestPerfettoSpan(llmSpanContext.perfettoSpanId, {
ttftMs: metadata?.ttftMs,
ttltMs: duration, // Time to last token is the total duration
promptTokens: metadata?.inputTokens,
outputTokens: metadata?.outputTokens,
cacheReadTokens: metadata?.cacheReadTokens,
cacheCreationTokens: metadata?.cacheCreationTokens,
success: metadata?.success,
error: metadata?.error,
requestSetupMs: metadata?.requestSetupMs,
attemptStartTimes: metadata?.attemptStartTimes,
})
}
if (!isAnyTracingEnabled()) {
const spanId = getSpanId(llmSpanContext.span)
activeSpans.delete(spanId)
strongSpans.delete(spanId)
return
}
const endAttributes: Record<string, string | number | boolean> = {
duration_ms: duration,
}
if (metadata) {
if (metadata.inputTokens !== undefined)
endAttributes['input_tokens'] = metadata.inputTokens
if (metadata.outputTokens !== undefined)
endAttributes['output_tokens'] = metadata.outputTokens
if (metadata.cacheReadTokens !== undefined)
endAttributes['cache_read_tokens'] = metadata.cacheReadTokens
if (metadata.cacheCreationTokens !== undefined)
endAttributes['cache_creation_tokens'] = metadata.cacheCreationTokens
if (metadata.success !== undefined)
endAttributes['success'] = metadata.success
if (metadata.statusCode !== undefined)
endAttributes['status_code'] = metadata.statusCode
if (metadata.error !== undefined) endAttributes['error'] = metadata.error
if (metadata.attempt !== undefined)
endAttributes['attempt'] = metadata.attempt
if (metadata.hasToolCall !== undefined)
endAttributes['response.has_tool_call'] = metadata.hasToolCall
if (metadata.ttftMs !== undefined)
endAttributes['ttft_ms'] = metadata.ttftMs
// Add experimental response attributes (model_output, thinking_output)
addBetaLLMResponseAttributes(endAttributes, metadata)
}
llmSpanContext.span.setAttributes(endAttributes)
llmSpanContext.span.end()
const spanId = getSpanId(llmSpanContext.span)
activeSpans.delete(spanId)
strongSpans.delete(spanId)
}
): void {}
export function startToolSpan(
toolName: string,
toolAttributes?: Record<string, string | number | boolean>,
toolInput?: string,
_toolName: string,
_toolAttributes?: SpanMetadata,
_toolInput?: string,
): Span {
// Start Perfetto span regardless of OTel tracing state
const perfettoSpanId = isPerfettoTracingEnabled()
? startToolPerfettoSpan(toolName, toolAttributes)
: undefined
if (!isAnyTracingEnabled()) {
// Still track Perfetto span even if OTel is disabled
if (perfettoSpanId) {
const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy')
const spanId = getSpanId(dummySpan)
const spanContextObj: SpanContext = {
span: dummySpan,
startTime: Date.now(),
attributes: { 'span.type': 'tool', tool_name: toolName },
perfettoSpanId,
}
activeSpans.set(spanId, new WeakRef(spanContextObj))
toolContext.enterWith(spanContextObj)
return dummySpan
}
return trace.getActiveSpan() || getTracer().startSpan('dummy')
}
const tracer = getTracer()
const parentSpanCtx = interactionContext.getStore()
const attributes = createSpanAttributes('tool', {
tool_name: toolName,
...toolAttributes,
})
const ctx = parentSpanCtx
? trace.setSpan(otelContext.active(), parentSpanCtx.span)
: otelContext.active()
const span = tracer.startSpan('claude_code.tool', { attributes }, ctx)
// Add experimental tool input attributes
if (toolInput) {
addBetaToolInputAttributes(span, toolName, toolInput)
}
const spanId = getSpanId(span)
const spanContextObj: SpanContext = {
span,
startTime: Date.now(),
attributes,
perfettoSpanId,
}
activeSpans.set(spanId, new WeakRef(spanContextObj))
toolContext.enterWith(spanContextObj)
const span = createNoopSpan()
currentToolSpan = span
return span
}
export function startToolBlockedOnUserSpan(): Span {
// Start Perfetto span regardless of OTel tracing state
const perfettoSpanId = isPerfettoTracingEnabled()
? startUserInputPerfettoSpan('tool_permission')
: undefined
if (!isAnyTracingEnabled()) {
// Still track Perfetto span even if OTel is disabled
if (perfettoSpanId) {
const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy')
const spanId = getSpanId(dummySpan)
const spanContextObj: SpanContext = {
span: dummySpan,
startTime: Date.now(),
attributes: { 'span.type': 'tool.blocked_on_user' },
perfettoSpanId,
}
activeSpans.set(spanId, new WeakRef(spanContextObj))
strongSpans.set(spanId, spanContextObj)
return dummySpan
}
return trace.getActiveSpan() || getTracer().startSpan('dummy')
}
const tracer = getTracer()
const parentSpanCtx = toolContext.getStore()
const attributes = createSpanAttributes('tool.blocked_on_user')
const ctx = parentSpanCtx
? trace.setSpan(otelContext.active(), parentSpanCtx.span)
: otelContext.active()
const span = tracer.startSpan(
'claude_code.tool.blocked_on_user',
{ attributes },
ctx,
)
const spanId = getSpanId(span)
const spanContextObj: SpanContext = {
span,
startTime: Date.now(),
attributes,
perfettoSpanId,
}
activeSpans.set(spanId, new WeakRef(spanContextObj))
strongSpans.set(spanId, spanContextObj)
return span
return createNoopSpan()
}
export function endToolBlockedOnUserSpan(
decision?: string,
source?: string,
): void {
const blockedSpanContext = Array.from(activeSpans.values())
.findLast(
r => r.deref()?.attributes['span.type'] === 'tool.blocked_on_user',
)
?.deref()
if (!blockedSpanContext) {
return
}
// End Perfetto span
if (blockedSpanContext.perfettoSpanId) {
endUserInputPerfettoSpan(blockedSpanContext.perfettoSpanId, {
decision,
source,
})
}
if (!isAnyTracingEnabled()) {
const spanId = getSpanId(blockedSpanContext.span)
activeSpans.delete(spanId)
strongSpans.delete(spanId)
return
}
const duration = Date.now() - blockedSpanContext.startTime
const attributes: Record<string, string | number | boolean> = {
duration_ms: duration,
}
if (decision) {
attributes['decision'] = decision
}
if (source) {
attributes['source'] = source
}
blockedSpanContext.span.setAttributes(attributes)
blockedSpanContext.span.end()
const spanId = getSpanId(blockedSpanContext.span)
activeSpans.delete(spanId)
strongSpans.delete(spanId)
}
_decision?: string,
_source?: string,
): void {}
export function startToolExecutionSpan(): Span {
if (!isAnyTracingEnabled()) {
return trace.getActiveSpan() || getTracer().startSpan('dummy')
}
const tracer = getTracer()
const parentSpanCtx = toolContext.getStore()
const attributes = createSpanAttributes('tool.execution')
const ctx = parentSpanCtx
? trace.setSpan(otelContext.active(), parentSpanCtx.span)
: otelContext.active()
const span = tracer.startSpan(
'claude_code.tool.execution',
{ attributes },
ctx,
)
const spanId = getSpanId(span)
const spanContextObj: SpanContext = {
span,
startTime: Date.now(),
attributes,
}
activeSpans.set(spanId, new WeakRef(spanContextObj))
strongSpans.set(spanId, spanContextObj)
return span
return createNoopSpan()
}
export function endToolExecutionSpan(metadata?: {
export function endToolExecutionSpan(_metadata?: {
success?: boolean
error?: string
}): void {
if (!isAnyTracingEnabled()) {
return
}
}): void {}
const executionSpanContext = Array.from(activeSpans.values())
.findLast(r => r.deref()?.attributes['span.type'] === 'tool.execution')
?.deref()
if (!executionSpanContext) {
return
}
const duration = Date.now() - executionSpanContext.startTime
const attributes: Record<string, string | number | boolean> = {
duration_ms: duration,
}
if (metadata) {
if (metadata.success !== undefined) attributes['success'] = metadata.success
if (metadata.error !== undefined) attributes['error'] = metadata.error
}
executionSpanContext.span.setAttributes(attributes)
executionSpanContext.span.end()
const spanId = getSpanId(executionSpanContext.span)
activeSpans.delete(spanId)
strongSpans.delete(spanId)
}
export function endToolSpan(toolResult?: string, resultTokens?: number): void {
const toolSpanContext = toolContext.getStore()
if (!toolSpanContext) {
return
}
// End Perfetto span
if (toolSpanContext.perfettoSpanId) {
endToolPerfettoSpan(toolSpanContext.perfettoSpanId, {
success: true,
resultTokens,
})
}
if (!isAnyTracingEnabled()) {
const spanId = getSpanId(toolSpanContext.span)
activeSpans.delete(spanId)
// Same reasoning as interactionContext above: clear so subsequent async
// work doesn't hold a stale reference to the ended tool span.
toolContext.enterWith(undefined)
return
}
const duration = Date.now() - toolSpanContext.startTime
const endAttributes: Record<string, string | number | boolean> = {
duration_ms: duration,
}
// Add experimental tool result attributes (new_context)
if (toolResult) {
const toolName = toolSpanContext.attributes['tool_name'] || 'unknown'
addBetaToolResultAttributes(endAttributes, toolName, toolResult)
}
if (resultTokens !== undefined) {
endAttributes['result_tokens'] = resultTokens
}
toolSpanContext.span.setAttributes(endAttributes)
toolSpanContext.span.end()
const spanId = getSpanId(toolSpanContext.span)
activeSpans.delete(spanId)
toolContext.enterWith(undefined)
}
function isToolContentLoggingEnabled(): boolean {
return isEnvTruthy(process.env.OTEL_LOG_TOOL_CONTENT)
}
/**
* Add a span event with tool content/output data.
* Only logs if OTEL_LOG_TOOL_CONTENT=1 is set.
* Truncates content if it exceeds MAX_CONTENT_SIZE.
*/
export function addToolContentEvent(
eventName: string,
attributes: Record<string, string | number | boolean>,
export function endToolSpan(
_toolResult?: string,
_resultTokens?: number,
): void {
if (!isAnyTracingEnabled() || !isToolContentLoggingEnabled()) {
return
}
const currentSpanCtx = toolContext.getStore()
if (!currentSpanCtx) {
return
}
// Truncate string attributes that might be large
const processedAttributes: Record<string, string | number | boolean> = {}
for (const [key, value] of Object.entries(attributes)) {
if (typeof value === 'string') {
const { content, truncated } = truncateContent(value)
processedAttributes[key] = content
if (truncated) {
processedAttributes[`${key}_truncated`] = true
processedAttributes[`${key}_original_length`] = value.length
}
} else {
processedAttributes[key] = value
}
}
currentSpanCtx.span.addEvent(eventName, processedAttributes)
currentToolSpan = null
}
export function addToolContentEvent(
_eventName: string,
_attributes: SpanMetadata,
): void {}
export function getCurrentSpan(): Span | null {
if (!isAnyTracingEnabled()) {
return null
}
return (
toolContext.getStore()?.span ?? interactionContext.getStore()?.span ?? null
)
return currentToolSpan ?? currentInteractionSpan
}
export async function executeInSpan<T>(
spanName: string,
_spanName: string,
fn: (span: Span) => Promise<T>,
attributes?: Record<string, string | number | boolean>,
_attributes?: SpanMetadata,
): Promise<T> {
if (!isAnyTracingEnabled()) {
return fn(trace.getActiveSpan() || getTracer().startSpan('dummy'))
}
const tracer = getTracer()
const parentSpanCtx = toolContext.getStore() ?? interactionContext.getStore()
const finalAttributes = createSpanAttributes('tool', {
...attributes,
})
const ctx = parentSpanCtx
? trace.setSpan(otelContext.active(), parentSpanCtx.span)
: otelContext.active()
const span = tracer.startSpan(spanName, { attributes: finalAttributes }, ctx)
const spanId = getSpanId(span)
const spanContextObj: SpanContext = {
span,
startTime: Date.now(),
attributes: finalAttributes,
}
activeSpans.set(spanId, new WeakRef(spanContextObj))
strongSpans.set(spanId, spanContextObj)
try {
const result = await fn(span)
span.end()
activeSpans.delete(spanId)
strongSpans.delete(spanId)
return result
} catch (error) {
if (error instanceof Error) {
span.recordException(error)
}
span.end()
activeSpans.delete(spanId)
strongSpans.delete(spanId)
throw error
}
return fn(createNoopSpan())
}
/**
* Start a hook execution span.
* Only creates a span when beta tracing is enabled.
* @param hookEvent The hook event type (e.g., 'PreToolUse', 'PostToolUse')
* @param hookName The full hook name (e.g., 'PreToolUse:Write')
* @param numHooks The number of hooks being executed
* @param hookDefinitions JSON string of hook definitions for tracing
* @returns The span (or a dummy span if tracing is disabled)
*/
export function startHookSpan(
hookEvent: string,
hookName: string,
numHooks: number,
hookDefinitions: string,
_hookEvent: string,
_hookName: string,
_numHooks: number,
_hookDefinitions: string,
): Span {
if (!isBetaTracingEnabled()) {
return trace.getActiveSpan() || getTracer().startSpan('dummy')
}
const tracer = getTracer()
const parentSpanCtx = toolContext.getStore() ?? interactionContext.getStore()
const attributes = createSpanAttributes('hook', {
hook_event: hookEvent,
hook_name: hookName,
num_hooks: numHooks,
hook_definitions: hookDefinitions,
})
const ctx = parentSpanCtx
? trace.setSpan(otelContext.active(), parentSpanCtx.span)
: otelContext.active()
const span = tracer.startSpan('claude_code.hook', { attributes }, ctx)
const spanId = getSpanId(span)
const spanContextObj: SpanContext = {
span,
startTime: Date.now(),
attributes,
}
activeSpans.set(spanId, new WeakRef(spanContextObj))
strongSpans.set(spanId, spanContextObj)
return span
return createNoopSpan()
}
/**
* End a hook execution span with outcome metadata.
* Only does work when beta tracing is enabled.
* @param span The span to end (returned from startHookSpan)
* @param metadata The outcome metadata for the hook execution
*/
export function endHookSpan(
span: Span,
metadata?: {
_span: Span,
_metadata?: {
numSuccess?: number
numBlocking?: number
numNonBlockingError?: number
numCancelled?: number
},
): void {
if (!isBetaTracingEnabled()) {
return
}
const spanId = getSpanId(span)
const spanContext = activeSpans.get(spanId)?.deref()
if (!spanContext) {
return
}
const duration = Date.now() - spanContext.startTime
const endAttributes: Record<string, string | number | boolean> = {
duration_ms: duration,
}
if (metadata) {
if (metadata.numSuccess !== undefined)
endAttributes['num_success'] = metadata.numSuccess
if (metadata.numBlocking !== undefined)
endAttributes['num_blocking'] = metadata.numBlocking
if (metadata.numNonBlockingError !== undefined)
endAttributes['num_non_blocking_error'] = metadata.numNonBlockingError
if (metadata.numCancelled !== undefined)
endAttributes['num_cancelled'] = metadata.numCancelled
}
spanContext.span.setAttributes(endAttributes)
spanContext.span.end()
activeSpans.delete(spanId)
strongSpans.delete(spanId)
}
): void {}