From 7a558e9640281fd9cdceeefa2f920969b62e0f63 Mon Sep 17 00:00:00 2001 From: paoloanzn Date: Tue, 31 Mar 2026 16:07:34 +0200 Subject: [PATCH] refactor: disable telemetry backends --- src/entrypoints/init.ts | 134 +-- src/services/analytics/datadog.ts | 311 +----- .../analytics/firstPartyEventLogger.ts | 439 +-------- src/services/analytics/index.ts | 157 +-- src/services/analytics/sink.ts | 112 +-- src/utils/telemetry/betaSessionTracing.ts | 509 +--------- src/utils/telemetry/events.ts | 73 +- src/utils/telemetry/instrumentation.ts | 823 +--------------- src/utils/telemetry/sessionTracing.ts | 910 ++---------------- 9 files changed, 168 insertions(+), 3300 deletions(-) diff --git a/src/entrypoints/init.ts b/src/entrypoints/init.ts index 69eb01d..e03a6a5 100644 --- a/src/entrypoints/init.ts +++ b/src/entrypoints/init.ts @@ -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 => { const initStartTime = Date.now() logForDiagnosticsNoPII('info', 'init_started') @@ -87,24 +72,6 @@ export const init = memoize(async (): Promise => { 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 => { }) /** - * 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 { - 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 { - // 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 } diff --git a/src/services/analytics/datadog.ts b/src/services/analytics/datadog.ts index 2f8bdf3..53433c8 100644 --- a/src/services/analytics/datadog.ts +++ b/src/services/analytics/datadog.ts @@ -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 { - 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 => { - 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 { - if (flushTimer) { - clearTimeout(flushTimer) - flushTimer = null - } - await flushLogs() -} -// NOTE: use via src/services/analytics/index.ts > logEvent +export const initializeDatadog = async (): Promise => false + +export async function shutdownDatadog(): Promise {} + export async function trackDatadogEvent( - eventName: string, - properties: { [key: string]: boolean | number | undefined }, -): Promise { - 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 = { - ...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: 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 {} diff --git a/src/services/analytics/firstPartyEventLogger.ts b/src/services/analytics/firstPartyEventLogger.ts index e3a501d..8024c64 100644 --- a/src/services/analytics/firstPartyEventLogger.ts +++ b/src/services/analytics/firstPartyEventLogger.ts @@ -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( - 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( - BATCH_CONFIG_NAME, - {}, - ) -} +export async function shutdown1PEventLogging(): Promise {} -// Module-local state for event logging (not exposed globally) -let firstPartyEventLogger: ReturnType | 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 { - 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 = {}, -): Promise { - 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 = {}, -): void { - if (!is1PEventLoggingEnabled()) { - return - } + _eventName: string, + _metadata: Record = {}, +): 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 } -// 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 = { - [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 { - 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 {} diff --git a/src/services/analytics/index.ts b/src/services/analytics/index.ts index 30d2e59..327e7e2 100644 --- a/src/services/analytics/index.ts +++ b/src/services/analytics/index.ts @@ -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( metadata: Record, ): Record { - let result: Record | 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 } -// 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 { - if (sink === null) { - eventQueue.push({ eventName, metadata, async: true }) - return - } - await sink.logEventAsync(eventName, metadata) -} + _eventName: string, + _metadata: LogEventMetadata, +): Promise {} -/** - * Reset analytics state for testing purposes only. - * @internal - */ -export function _resetForTesting(): void { - sink = null - eventQueue.length = 0 -} +export function _resetForTesting(): void {} diff --git a/src/services/analytics/sink.ts b/src/services/analytics/sink.ts index a7b7021..d569c2b 100644 --- a/src/services/analytics/sink.ts +++ b/src/services/analytics/sink.ts @@ -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 { - 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 {} diff --git a/src/utils/telemetry/betaSessionTracing.ts b/src/utils/telemetry/betaSessionTracing.ts index 6699721..0579c6b 100644 --- a/src/utils/telemetry/betaSessionTracing.ts +++ b/src/utils/telemetry/betaSessionTracing.ts @@ -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() - -/** - * 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() - -/** - * 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 tags -const SYSTEM_REMINDER_REGEX = - /^\n?([\s\S]*?)\n?<\/system-reminder>$/ - -/** - * Check if text is entirely a system reminder (wrapped in 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): void + addEvent( + name: string, + attributes?: Record, + ): 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, - metadata?: { + _endAttributes: Record, + _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, - 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, + _toolName: string | number | boolean, + _toolResult: string, +): void {} diff --git a/src/utils/telemetry/events.ts b/src/utils/telemetry/events.ts index f93b8aa..36ed388 100644 --- a/src/utils/telemetry/events.ts +++ b/src/utils/telemetry/events.ts @@ -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 { - 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 {} diff --git a/src/utils/telemetry/instrumentation.ts b/src/utils/telemetry/instrumentation.ts index 224f048..e6be002 100644 --- a/src/utils/telemetry/instrumentation.ts +++ b/src/utils/telemetry/instrumentation.ts @@ -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 { - 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 { + 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, -): Promise { - 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= - 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 = { - [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[] = [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 { - 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 { - const headers: Record = {} - 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 = {} - - // 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> => { - const dynamicHeaders = getOtelHeadersFromHelper() - return { ...staticHeaders, ...dynamicHeaders } - } - } else if (Object.keys(staticHeaders).length > 0) { - config.headers = async (): Promise> => 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 {} diff --git a/src/utils/telemetry/sessionTracing.ts b/src/utils/telemetry/sessionTracing.ts index 0ee4f38..8e940dc 100644 --- a/src/utils/telemetry/sessionTracing.ts +++ b/src/utils/telemetry/sessionTracing.ts @@ -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 - ended?: boolean - perfettoSpanId?: string +export interface Span { + setAttribute(name: string, value: string | number | boolean): void + setAttributes(attributes: Record): void + addEvent( + name: string, + attributes?: Record, + ): 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() -const toolContext = new AsyncLocalStorage() -const activeSpans = new Map>() -// 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() -let interactionSequence = 0 -let _cleanupIntervalStarted = false +type SpanMetadata = Record -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 = {}, -): Record { - const baseAttributes = getTelemetryAttributes() - - const attributes: Record = { - ...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 : '' - - 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 = { - 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, - 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 = { - 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 = { - 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 = { - 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, +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 = {} - 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( - spanName: string, + _spanName: string, fn: (span: Span) => Promise, - attributes?: Record, + _attributes?: SpanMetadata, ): Promise { - 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 = { - 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 {}