From 642acae41a1cb429a740a91ccf439b0633ec93e0 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 14:09:05 -0700 Subject: [PATCH 01/12] feat(node): Add Claude Code Agent SDK instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Sentry tracing instrumentation for the @anthropic-ai/claude-agent-sdk following OpenTelemetry Semantic Conventions for Generative AI. Key features: - Captures agent invocation, LLM chat, and tool execution spans - Records token usage, model info, and session tracking - Supports input/output recording based on sendDefaultPii setting - Provides createInstrumentedClaudeQuery() helper for clean DX Due to ESM-only module constraints, this integration uses a helper function pattern instead of automatic OpenTelemetry instrumentation hooks. Usage: ```typescript import { createInstrumentedClaudeQuery } from '@sentry/node'; const query = createInstrumentedClaudeQuery(); ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/node/src/index.ts | 2 + .../tracing/claude-code/helpers.ts | 114 ++++++ .../integrations/tracing/claude-code/index.ts | 130 +++++++ .../tracing/claude-code/instrumentation.ts | 368 ++++++++++++++++++ .../node/src/integrations/tracing/index.ts | 3 + 5 files changed, 617 insertions(+) create mode 100644 packages/node/src/integrations/tracing/claude-code/helpers.ts create mode 100644 packages/node/src/integrations/tracing/claude-code/index.ts create mode 100644 packages/node/src/integrations/tracing/claude-code/instrumentation.ts diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index bb655b87fc42..2481be129e9c 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -26,6 +26,8 @@ export { amqplibIntegration } from './integrations/tracing/amqplib'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { openAIIntegration } from './integrations/tracing/openai'; export { anthropicAIIntegration } from './integrations/tracing/anthropic-ai'; +export { claudeCodeIntegration, patchClaudeCodeQuery } from './integrations/tracing/claude-code'; +export { createInstrumentedClaudeQuery } from './integrations/tracing/claude-code/helpers'; export { googleGenAIIntegration } from './integrations/tracing/google-genai'; export { langChainIntegration } from './integrations/tracing/langchain'; export { langGraphIntegration } from './integrations/tracing/langgraph'; diff --git a/packages/node/src/integrations/tracing/claude-code/helpers.ts b/packages/node/src/integrations/tracing/claude-code/helpers.ts new file mode 100644 index 000000000000..ba6b2bbc1323 --- /dev/null +++ b/packages/node/src/integrations/tracing/claude-code/helpers.ts @@ -0,0 +1,114 @@ +import { getClient } from '@sentry/core'; +import { patchClaudeCodeQuery } from './instrumentation'; +import type { ClaudeCodeOptions } from './index'; + +const CLAUDE_CODE_INTEGRATION_NAME = 'ClaudeCode'; + +// Global singleton - only patch once per application instance +let _globalPatchedQuery: ((...args: unknown[]) => AsyncGenerator) | null = null; +let _initPromise: Promise | null = null; + +/** + * Lazily loads and patches the Claude Code SDK. + * Ensures only one patched instance exists globally. + */ +async function ensurePatchedQuery(): Promise { + if (_globalPatchedQuery) { + return; + } + + if (_initPromise) { + return _initPromise; + } + + _initPromise = (async () => { + try { + // Use webpackIgnore to prevent webpack from trying to resolve this at build time + // The import resolves at runtime from the user's node_modules + const sdkPath = '@anthropic-ai/claude-agent-sdk'; + const claudeSDK = await import(/* webpackIgnore: true */ sdkPath); + + if (!claudeSDK || typeof claudeSDK.query !== 'function') { + throw new Error( + `Failed to find 'query' function in @anthropic-ai/claude-agent-sdk.\n` + + `Make sure you have version >=0.1.0 installed.`, + ); + } + + const client = getClient(); + const integration = client?.getIntegrationByName(CLAUDE_CODE_INTEGRATION_NAME); + const options = integration?.options || {}; + + _globalPatchedQuery = patchClaudeCodeQuery(claudeSDK.query, options); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'Unknown error occurred while loading @anthropic-ai/claude-agent-sdk'; + + throw new Error( + `Failed to instrument Claude Code SDK:\n${errorMessage}\n\n` + + `Make sure @anthropic-ai/claude-agent-sdk is installed:\n` + + ` npm install @anthropic-ai/claude-agent-sdk\n` + + ` # or\n` + + ` yarn add @anthropic-ai/claude-agent-sdk`, + ); + } + })(); + + return _initPromise; +} + +/** + * Creates a Sentry-instrumented query function for the Claude Code SDK. + * + * This is a convenience helper that reduces boilerplate to a single line. + * The SDK is lazily loaded on first query call, and the patched version is cached globally. + * + * **Important**: This helper is NOT automatic. You must call it in your code. + * The Claude Code SDK cannot be automatically instrumented due to ESM module + * and webpack bundling limitations. + * + * @returns An instrumented query function ready to use + * + * @example + * ```typescript + * import { createInstrumentedClaudeQuery } from '@sentry/node'; + * import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; + * + * const query = createInstrumentedClaudeQuery(); + * + * // Use as normal - automatically instrumented! + * for await (const message of query({ + * prompt: 'Hello', + * options: { model: 'claude-sonnet-4-5' } + * })) { + * console.log(message); + * } + * ``` + * + * Configuration is automatically pulled from your `claudeCodeIntegration()` setup: + * + * @example + * ```typescript + * Sentry.init({ + * integrations: [ + * Sentry.claudeCodeIntegration({ + * recordInputs: true, // These options are used + * recordOutputs: true, // by createInstrumentedClaudeQuery() + * }) + * ] + * }); + * ``` + */ +export function createInstrumentedClaudeQuery(): (...args: unknown[]) => AsyncGenerator { + return async function* query(...args: unknown[]): AsyncGenerator { + await ensurePatchedQuery(); + + if (!_globalPatchedQuery) { + throw new Error('[Sentry] Failed to initialize instrumented Claude Code query function'); + } + + yield* _globalPatchedQuery(...args); + }; +} diff --git a/packages/node/src/integrations/tracing/claude-code/index.ts b/packages/node/src/integrations/tracing/claude-code/index.ts new file mode 100644 index 000000000000..ca4009ab0826 --- /dev/null +++ b/packages/node/src/integrations/tracing/claude-code/index.ts @@ -0,0 +1,130 @@ +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { patchClaudeCodeQuery } from './instrumentation'; + +export interface ClaudeCodeOptions { + /** + * Whether to record prompt messages. + * Defaults to Sentry client's `sendDefaultPii` setting. + */ + recordInputs?: boolean; + + /** + * Whether to record response text, tool calls, and tool outputs. + * Defaults to Sentry client's `sendDefaultPii` setting. + */ + recordOutputs?: boolean; +} + +const CLAUDE_CODE_INTEGRATION_NAME = 'ClaudeCode'; + +const _claudeCodeIntegration = ((options: ClaudeCodeOptions = {}) => { + return { + name: CLAUDE_CODE_INTEGRATION_NAME, + options, + setupOnce() { + // Note: Automatic patching via require hooks doesn't work for ESM modules + // or webpack-bundled dependencies. Users must manually patch using patchClaudeCodeQuery() + // in their route files. + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for the Claude Code SDK. + * + * **Important**: Due to ESM module and bundler limitations, this integration requires + * using the `createInstrumentedClaudeQuery()` helper function in your code. + * See the example below for proper usage. + * + * This integration captures telemetry data following OpenTelemetry Semantic Conventions + * for Generative AI, including: + * - Agent invocation spans (`invoke_agent`) + * - LLM chat spans (`chat`) + * - Tool execution spans (`execute_tool`) + * - Token usage, model info, and session tracking + * + * @example + * ```typescript + * // Step 1: Configure the integration + * import * as Sentry from '@sentry/node'; + * + * Sentry.init({ + * dsn: 'your-dsn', + * integrations: [ + * Sentry.claudeCodeIntegration({ + * recordInputs: true, + * recordOutputs: true + * }) + * ], + * }); + * + * // Step 2: Use the helper in your routes + * import { createInstrumentedClaudeQuery } from '@sentry/node'; + * + * const query = createInstrumentedClaudeQuery(); + * + * // Use query as normal - automatically instrumented! + * for await (const message of query({ + * prompt: 'Hello', + * options: { model: 'claude-sonnet-4-5' } + * })) { + * console.log(message); + * } + * ``` + * + * ## Options + * + * - `recordInputs`: Whether to record prompt messages (default: respects `sendDefaultPii` client option) + * - `recordOutputs`: Whether to record response text, tool calls, and outputs (default: respects `sendDefaultPii` client option) + * + * ### Default Behavior + * + * By default, the integration will: + * - Record inputs and outputs ONLY if `sendDefaultPii` is set to `true` in your Sentry client options + * - Otherwise, inputs and outputs are NOT recorded unless explicitly enabled + * + * @example + * ```typescript + * // Record inputs and outputs when sendDefaultPii is false + * Sentry.init({ + * integrations: [ + * Sentry.claudeCodeIntegration({ + * recordInputs: true, + * recordOutputs: true + * }) + * ], + * }); + * + * // Never record inputs/outputs regardless of sendDefaultPii + * Sentry.init({ + * sendDefaultPii: true, + * integrations: [ + * Sentry.claudeCodeIntegration({ + * recordInputs: false, + * recordOutputs: false + * }) + * ], + * }); + * ``` + * + * @see https://docs.sentry.io/platforms/javascript/guides/node/ai-monitoring/ + */ +export const claudeCodeIntegration = defineIntegration(_claudeCodeIntegration); + +/** + * Manually patch the Claude Code SDK query function with Sentry instrumentation. + * + * **Note**: Most users should use `createInstrumentedClaudeQuery()` instead, + * which is simpler and handles option retrieval automatically. + * + * This low-level function is exported for advanced use cases where you need + * explicit control over the patching process. + * + * @param queryFunction - The original query function from @anthropic-ai/claude-agent-sdk + * @param options - Instrumentation options (recordInputs, recordOutputs) + * @returns Instrumented query function + * + * @see createInstrumentedClaudeQuery for the recommended high-level helper + */ +export { patchClaudeCodeQuery }; diff --git a/packages/node/src/integrations/tracing/claude-code/instrumentation.ts b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts new file mode 100644 index 000000000000..76ee16ad40ba --- /dev/null +++ b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts @@ -0,0 +1,368 @@ +import type { Span } from '@opentelemetry/api'; +import { getClient, startSpanManual, withActiveSpan, startSpan } from '@sentry/core'; +import type { ClaudeCodeOptions } from './index'; + +type ClaudeCodeInstrumentationOptions = ClaudeCodeOptions; + +const GEN_AI_ATTRIBUTES = { + SYSTEM: 'gen_ai.system', + OPERATION_NAME: 'gen_ai.operation.name', + REQUEST_MODEL: 'gen_ai.request.model', + REQUEST_MESSAGES: 'gen_ai.request.messages', + RESPONSE_TEXT: 'gen_ai.response.text', + RESPONSE_TOOL_CALLS: 'gen_ai.response.tool_calls', + RESPONSE_ID: 'gen_ai.response.id', + RESPONSE_MODEL: 'gen_ai.response.model', + USAGE_INPUT_TOKENS: 'gen_ai.usage.input_tokens', + USAGE_OUTPUT_TOKENS: 'gen_ai.usage.output_tokens', + USAGE_TOTAL_TOKENS: 'gen_ai.usage.total_tokens', + TOOL_NAME: 'gen_ai.tool.name', + TOOL_INPUT: 'gen_ai.tool.input', + TOOL_OUTPUT: 'gen_ai.tool.output', + AGENT_NAME: 'gen_ai.agent.name', +} as const; + +const SENTRY_ORIGIN = 'auto.ai.claude-code'; + +function setTokenUsageAttributes( + span: Span, + inputTokens?: number, + outputTokens?: number, + cacheCreationTokens?: number, + cacheReadTokens?: number, +): void { + const attrs: Record = {}; + + if (typeof inputTokens === 'number') { + attrs[GEN_AI_ATTRIBUTES.USAGE_INPUT_TOKENS] = inputTokens; + } + if (typeof outputTokens === 'number') { + attrs[GEN_AI_ATTRIBUTES.USAGE_OUTPUT_TOKENS] = outputTokens; + } + + const total = (inputTokens ?? 0) + (outputTokens ?? 0) + (cacheCreationTokens ?? 0) + (cacheReadTokens ?? 0); + if (total > 0) { + attrs[GEN_AI_ATTRIBUTES.USAGE_TOTAL_TOKENS] = total; + } + + if (Object.keys(attrs).length > 0) { + span.setAttributes(attrs); + } +} + +/** + * Patches the Claude Code SDK query function with Sentry instrumentation. + * This function can be called directly to patch an imported query function. + */ +export function patchClaudeCodeQuery( + queryFunction: (...args: unknown[]) => AsyncGenerator, + options: ClaudeCodeInstrumentationOptions = {}, +): (...args: unknown[]) => AsyncGenerator { + const patchedQuery = function (this: unknown, ...args: unknown[]): AsyncGenerator { + const client = getClient(); + const defaultPii = Boolean(client?.getOptions().sendDefaultPii); + + const recordInputs = options.recordInputs ?? defaultPii; + const recordOutputs = options.recordOutputs ?? defaultPii; + + // Parse query arguments + const [queryParams] = args as [Record]; + const { options: queryOptions, inputMessages } = queryParams || {}; + const model = (queryOptions as Record)?.model ?? 'sonnet'; + + // Create original query instance + const originalQueryInstance = queryFunction.apply(this, args); + + // Create instrumented generator + const instrumentedGenerator = _createInstrumentedGenerator( + originalQueryInstance, + model as string, + { recordInputs, recordOutputs, inputMessages }, + ); + + // Preserve Query interface methods + if (typeof (originalQueryInstance as Record).interrupt === 'function') { + (instrumentedGenerator as unknown as Record).interrupt = ( + (originalQueryInstance as Record).interrupt as Function + ).bind(originalQueryInstance); + } + if (typeof (originalQueryInstance as Record).setPermissionMode === 'function') { + (instrumentedGenerator as unknown as Record).setPermissionMode = ( + (originalQueryInstance as Record).setPermissionMode as Function + ).bind(originalQueryInstance); + } + + return instrumentedGenerator; + }; + + return patchedQuery as typeof queryFunction; +} + +/** + * Creates an instrumented async generator that wraps the original query. + */ +function _createInstrumentedGenerator( + originalQuery: AsyncGenerator, + model: string, + instrumentationOptions: { recordInputs?: boolean; recordOutputs?: boolean; inputMessages?: unknown }, +): AsyncGenerator { + return startSpanManual( + { + name: `invoke_agent claude-code`, + op: 'gen_ai.invoke_agent', + attributes: { + [GEN_AI_ATTRIBUTES.SYSTEM]: 'claude-code', + [GEN_AI_ATTRIBUTES.REQUEST_MODEL]: model, + [GEN_AI_ATTRIBUTES.OPERATION_NAME]: 'invoke_agent', + [GEN_AI_ATTRIBUTES.AGENT_NAME]: 'claude-code', + 'sentry.origin': SENTRY_ORIGIN, + }, + }, + async function* (span: Span) { + // State accumulation + let sessionId: string | null = null; + let currentLLMSpan: Span | null = null; + let currentTurnContent = ''; + let currentTurnTools: unknown[] = []; + let currentTurnId: string | null = null; + let currentTurnModel: string | null = null; + let inputMessagesCaptured = false; + let finalResult: string | null = null; + let previousLLMSpan: Span | null = null; + let previousTurnTools: unknown[] = []; + + try { + for await (const message of originalQuery) { + const msg = message as Record; + + // Extract session ID from system message + if (msg.type === 'system' && msg.session_id) { + sessionId = msg.session_id as string; + + if ( + !inputMessagesCaptured && + instrumentationOptions.recordInputs && + msg.conversation_history + ) { + span.setAttributes({ + [GEN_AI_ATTRIBUTES.REQUEST_MESSAGES]: JSON.stringify(msg.conversation_history), + }); + inputMessagesCaptured = true; + } + } + + // Handle assistant messages + if (msg.type === 'assistant') { + // Close previous LLM span if still open + if (previousLLMSpan) { + previousLLMSpan.setStatus({ code: 1 }); + previousLLMSpan.end(); + previousLLMSpan = null; + previousTurnTools = []; + } + + // Create new LLM span + if (!currentLLMSpan) { + currentLLMSpan = withActiveSpan(span, () => { + return startSpanManual( + { + name: `chat ${model}`, + op: 'gen_ai.chat', + attributes: { + [GEN_AI_ATTRIBUTES.SYSTEM]: 'claude-code', + [GEN_AI_ATTRIBUTES.REQUEST_MODEL]: model, + [GEN_AI_ATTRIBUTES.OPERATION_NAME]: 'chat', + 'sentry.origin': SENTRY_ORIGIN, + }, + }, + (childSpan: Span) => { + if (instrumentationOptions.recordInputs && instrumentationOptions.inputMessages) { + childSpan.setAttributes({ + [GEN_AI_ATTRIBUTES.REQUEST_MESSAGES]: JSON.stringify( + instrumentationOptions.inputMessages, + ), + }); + } + return childSpan; + }, + ); + }); + + currentTurnContent = ''; + currentTurnTools = []; + } + + // Accumulate content + const content = (msg.message as Record)?.content as unknown[]; + if (Array.isArray(content)) { + const textContent = content + .filter((c) => (c as Record).type === 'text') + .map((c) => (c as Record).text as string) + .join(''); + if (textContent) { + currentTurnContent += textContent; + } + + const tools = content.filter((c) => (c as Record).type === 'tool_use'); + if (tools.length > 0) { + currentTurnTools.push(...tools); + } + } + + if ((msg.message as Record)?.id) { + currentTurnId = (msg.message as Record).id as string; + } + if ((msg.message as Record)?.model) { + currentTurnModel = (msg.message as Record).model as string; + } + } + + // Handle result messages + if (msg.type === 'result') { + if (msg.result) { + finalResult = msg.result as string; + } + + // Close previous LLM span + if (previousLLMSpan) { + previousLLMSpan.setStatus({ code: 1 }); + previousLLMSpan.end(); + previousLLMSpan = null; + previousTurnTools = []; + } + + // Finalize current LLM span + if (currentLLMSpan) { + if (instrumentationOptions.recordOutputs && currentTurnContent) { + currentLLMSpan.setAttributes({ + [GEN_AI_ATTRIBUTES.RESPONSE_TEXT]: currentTurnContent, + }); + } + + if (instrumentationOptions.recordOutputs && currentTurnTools.length > 0) { + currentLLMSpan.setAttributes({ + [GEN_AI_ATTRIBUTES.RESPONSE_TOOL_CALLS]: JSON.stringify(currentTurnTools), + }); + } + + if (currentTurnId) { + currentLLMSpan.setAttributes({ + [GEN_AI_ATTRIBUTES.RESPONSE_ID]: currentTurnId, + }); + } + if (currentTurnModel) { + currentLLMSpan.setAttributes({ + [GEN_AI_ATTRIBUTES.RESPONSE_MODEL]: currentTurnModel, + }); + } + + if (msg.usage) { + const usage = msg.usage as Record; + setTokenUsageAttributes( + currentLLMSpan, + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + ); + } + + currentLLMSpan.setStatus({ code: 1 }); + currentLLMSpan.end(); + + previousLLMSpan = currentLLMSpan; + previousTurnTools = currentTurnTools; + + currentLLMSpan = null; + currentTurnContent = ''; + currentTurnTools = []; + currentTurnId = null; + currentTurnModel = null; + } + } + + // Handle tool results + if (msg.type === 'user' && (msg.message as Record)?.content) { + const content = (msg.message as Record).content as unknown[]; + const toolResults = Array.isArray(content) + ? content.filter((c) => (c as Record).type === 'tool_result') + : []; + + for (const toolResult of toolResults) { + const tr = toolResult as Record; + let matchingTool = currentTurnTools.find( + (t) => (t as Record).id === tr.tool_use_id, + ) as Record | undefined; + let parentLLMSpan = currentLLMSpan; + + if (!matchingTool && previousTurnTools.length > 0) { + matchingTool = previousTurnTools.find( + (t) => (t as Record).id === tr.tool_use_id, + ) as Record | undefined; + parentLLMSpan = previousLLMSpan; + } + + if (matchingTool && parentLLMSpan) { + withActiveSpan(parentLLMSpan, () => { + startSpan( + { + name: `execute_tool ${matchingTool!.name as string}`, + op: 'gen_ai.execute_tool', + attributes: { + [GEN_AI_ATTRIBUTES.SYSTEM]: 'claude-code', + [GEN_AI_ATTRIBUTES.REQUEST_MODEL]: model, + [GEN_AI_ATTRIBUTES.OPERATION_NAME]: 'execute_tool', + [GEN_AI_ATTRIBUTES.AGENT_NAME]: 'claude-code', + [GEN_AI_ATTRIBUTES.TOOL_NAME]: matchingTool!.name as string, + 'sentry.origin': SENTRY_ORIGIN, + }, + }, + (toolSpan: Span) => { + if (instrumentationOptions.recordInputs && matchingTool!.input) { + toolSpan.setAttributes({ + [GEN_AI_ATTRIBUTES.TOOL_INPUT]: JSON.stringify(matchingTool!.input), + }); + } + + if (instrumentationOptions.recordOutputs && tr.content) { + toolSpan.setAttributes({ + [GEN_AI_ATTRIBUTES.TOOL_OUTPUT]: + typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content), + }); + } + + if (tr.is_error) { + toolSpan.setStatus({ code: 2, message: 'Tool execution error' }); + } + }, + ); + }); + } + } + } + + yield message; + } + + if (instrumentationOptions.recordOutputs && finalResult) { + span.setAttributes({ + [GEN_AI_ATTRIBUTES.RESPONSE_TEXT]: finalResult, + }); + } + + if (sessionId) { + span.setAttributes({ + [GEN_AI_ATTRIBUTES.RESPONSE_ID]: sessionId, + }); + } + + span.setStatus({ code: 1 }); + } catch (error) { + span.setStatus({ code: 2, message: (error as Error).message }); + throw error; + } finally { + span.end(); + } + }, + ); +} diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index dcd2efa5595c..268c437a0024 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -2,6 +2,7 @@ import type { Integration } from '@sentry/core'; import { instrumentOtelHttp, instrumentSentryHttp } from '../http'; import { amqplibIntegration, instrumentAmqplib } from './amqplib'; import { anthropicAIIntegration, instrumentAnthropicAi } from './anthropic-ai'; +import { claudeCodeAgentSdkIntegration, instrumentClaudeCodeAgentSdk } from './claude-code'; import { connectIntegration, instrumentConnect } from './connect'; import { expressIntegration, instrumentExpress } from './express'; import { fastifyIntegration, instrumentFastify, instrumentFastifyV3 } from './fastify'; @@ -62,6 +63,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { googleGenAIIntegration(), postgresJsIntegration(), firebaseIntegration(), + claudeCodeAgentSdkIntegration(), ]; } @@ -101,5 +103,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentAnthropicAi, instrumentGoogleGenAI, instrumentLangGraph, + instrumentClaudeCodeAgentSdk, ]; } From a3de1846c867025eab01e404c338963ccec6874f Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 14:20:56 -0700 Subject: [PATCH 02/12] fix(node): Reset init state on Claude Code instrumentation failure to allow retry --- packages/node/src/integrations/tracing/claude-code/helpers.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/node/src/integrations/tracing/claude-code/helpers.ts b/packages/node/src/integrations/tracing/claude-code/helpers.ts index ba6b2bbc1323..7e5a20ce18ea 100644 --- a/packages/node/src/integrations/tracing/claude-code/helpers.ts +++ b/packages/node/src/integrations/tracing/claude-code/helpers.ts @@ -41,6 +41,9 @@ async function ensurePatchedQuery(): Promise { _globalPatchedQuery = patchClaudeCodeQuery(claudeSDK.query, options); } catch (error) { + // Reset state on failure to allow retry on next call + _initPromise = null; + const errorMessage = error instanceof Error ? error.message From 65ad092254e3b69fe28cec9126f57b9cc68cf78d Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 14:33:12 -0700 Subject: [PATCH 03/12] fix(node): Use SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN constant instead of string literal --- .../tracing/claude-code/instrumentation.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/node/src/integrations/tracing/claude-code/instrumentation.ts b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts index 76ee16ad40ba..6e906f9b522f 100644 --- a/packages/node/src/integrations/tracing/claude-code/instrumentation.ts +++ b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts @@ -1,5 +1,11 @@ import type { Span } from '@opentelemetry/api'; -import { getClient, startSpanManual, withActiveSpan, startSpan } from '@sentry/core'; +import { + getClient, + startSpanManual, + withActiveSpan, + startSpan, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, +} from '@sentry/core'; import type { ClaudeCodeOptions } from './index'; type ClaudeCodeInstrumentationOptions = ClaudeCodeOptions; @@ -115,7 +121,7 @@ function _createInstrumentedGenerator( [GEN_AI_ATTRIBUTES.REQUEST_MODEL]: model, [GEN_AI_ATTRIBUTES.OPERATION_NAME]: 'invoke_agent', [GEN_AI_ATTRIBUTES.AGENT_NAME]: 'claude-code', - 'sentry.origin': SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, }, }, async function* (span: Span) { @@ -172,7 +178,7 @@ function _createInstrumentedGenerator( [GEN_AI_ATTRIBUTES.SYSTEM]: 'claude-code', [GEN_AI_ATTRIBUTES.REQUEST_MODEL]: model, [GEN_AI_ATTRIBUTES.OPERATION_NAME]: 'chat', - 'sentry.origin': SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, }, }, (childSpan: Span) => { @@ -314,7 +320,7 @@ function _createInstrumentedGenerator( [GEN_AI_ATTRIBUTES.OPERATION_NAME]: 'execute_tool', [GEN_AI_ATTRIBUTES.AGENT_NAME]: 'claude-code', [GEN_AI_ATTRIBUTES.TOOL_NAME]: matchingTool!.name as string, - 'sentry.origin': SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, }, }, (toolSpan: Span) => { From 517b3b25dad8a578192ea1ce3d806249d7444c99 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 14:46:50 -0700 Subject: [PATCH 04/12] fix(node): Add SEMANTIC_ATTRIBUTE_SENTRY_OP and improve error handling in Claude Code integration - Add SEMANTIC_ATTRIBUTE_SENTRY_OP to all span creation calls (invoke_agent, chat, execute_tool) - Capture exceptions to Sentry in catch block with proper mechanism metadata - Ensure child spans (currentLLMSpan, previousLLMSpan) are always closed in finally block - Prevents incomplete traces if generator exits early --- .../tracing/claude-code/instrumentation.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/node/src/integrations/tracing/claude-code/instrumentation.ts b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts index 6e906f9b522f..822fecb2c76d 100644 --- a/packages/node/src/integrations/tracing/claude-code/instrumentation.ts +++ b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts @@ -4,7 +4,9 @@ import { startSpanManual, withActiveSpan, startSpan, + captureException, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_OP, } from '@sentry/core'; import type { ClaudeCodeOptions } from './index'; @@ -122,6 +124,7 @@ function _createInstrumentedGenerator( [GEN_AI_ATTRIBUTES.OPERATION_NAME]: 'invoke_agent', [GEN_AI_ATTRIBUTES.AGENT_NAME]: 'claude-code', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', }, }, async function* (span: Span) { @@ -179,6 +182,7 @@ function _createInstrumentedGenerator( [GEN_AI_ATTRIBUTES.REQUEST_MODEL]: model, [GEN_AI_ATTRIBUTES.OPERATION_NAME]: 'chat', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', }, }, (childSpan: Span) => { @@ -321,6 +325,7 @@ function _createInstrumentedGenerator( [GEN_AI_ATTRIBUTES.AGENT_NAME]: 'claude-code', [GEN_AI_ATTRIBUTES.TOOL_NAME]: matchingTool!.name as string, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.execute_tool', }, }, (toolSpan: Span) => { @@ -364,9 +369,28 @@ function _createInstrumentedGenerator( span.setStatus({ code: 1 }); } catch (error) { + // Capture exception to Sentry with proper metadata + captureException(error, { + mechanism: { + type: SENTRY_ORIGIN, + handled: false, + }, + }); + span.setStatus({ code: 2, message: (error as Error).message }); throw error; } finally { + // Ensure all child spans are closed even if generator exits early + if (currentLLMSpan && currentLLMSpan.isRecording()) { + currentLLMSpan.setStatus({ code: 1 }); + currentLLMSpan.end(); + } + + if (previousLLMSpan && previousLLMSpan.isRecording()) { + previousLLMSpan.setStatus({ code: 1 }); + previousLLMSpan.end(); + } + span.end(); } }, From 75792f563d9ad8c451bef1cd3f8e0adf67229db8 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 16:47:31 -0700 Subject: [PATCH 05/12] fix(node): Fix TypeScript types for createInstrumentedClaudeQuery --- packages/node/src/integrations/tracing/claude-code/helpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/node/src/integrations/tracing/claude-code/helpers.ts b/packages/node/src/integrations/tracing/claude-code/helpers.ts index 7e5a20ce18ea..cddc2d5835dc 100644 --- a/packages/node/src/integrations/tracing/claude-code/helpers.ts +++ b/packages/node/src/integrations/tracing/claude-code/helpers.ts @@ -36,8 +36,8 @@ async function ensurePatchedQuery(): Promise { } const client = getClient(); - const integration = client?.getIntegrationByName(CLAUDE_CODE_INTEGRATION_NAME); - const options = integration?.options || {}; + const integration = client?.getIntegrationByName(CLAUDE_CODE_INTEGRATION_NAME); + const options = (integration as any)?.options as ClaudeCodeOptions | undefined || {}; _globalPatchedQuery = patchClaudeCodeQuery(claudeSDK.query, options); } catch (error) { From ac948dbb91d3163f618e8ea9559f69ee833fe5a2 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 17:09:11 -0700 Subject: [PATCH 06/12] feat(nextjs): Export Claude Code integration types --- packages/nextjs/src/index.types.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 7c92fecd7834..92791809e05e 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -27,6 +27,11 @@ export declare const contextLinesIntegration: typeof clientSdk.contextLinesInteg // Different implementation in server and worker export declare const vercelAIIntegration: typeof serverSdk.vercelAIIntegration; +// Claude Code integration (server-only) +export declare const claudeCodeIntegration: typeof serverSdk.claudeCodeIntegration; +export declare const createInstrumentedClaudeQuery: typeof serverSdk.createInstrumentedClaudeQuery; +export declare const patchClaudeCodeQuery: typeof serverSdk.patchClaudeCodeQuery; + export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; From 8e16291d9ba4dce689d9d82eeb8c03028c20115f Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 17:14:04 -0700 Subject: [PATCH 07/12] feat(nextjs): Add explicit runtime exports for Claude Code integration --- packages/nextjs/src/server/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 7dc533e171b1..d2cad2bc9f0b 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -37,6 +37,9 @@ import { handleOnSpanStart } from './handleOnSpanStart'; export * from '@sentry/node'; +// Explicit re-exports for Claude Code integration +export { claudeCodeIntegration, createInstrumentedClaudeQuery, patchClaudeCodeQuery } from '@sentry/node'; + export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; // Override core span methods with Next.js-specific implementations that support Cache Components From 2f7f3d11cc80f044e410bf7f12783cd64967199e Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 17:33:54 -0700 Subject: [PATCH 08/12] fix(nextjs): Import Claude Code exports before re-exporting to prevent undefined --- packages/nextjs/src/server/index.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index d2cad2bc9f0b..d32018d4aeb8 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -20,7 +20,14 @@ import { stripUrlQueryAndFragment, } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; +import { + getDefaultIntegrations, + httpIntegration, + init as nodeInit, + claudeCodeIntegration, + createInstrumentedClaudeQuery, + patchClaudeCodeQuery, +} from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; @@ -38,7 +45,12 @@ import { handleOnSpanStart } from './handleOnSpanStart'; export * from '@sentry/node'; // Explicit re-exports for Claude Code integration -export { claudeCodeIntegration, createInstrumentedClaudeQuery, patchClaudeCodeQuery } from '@sentry/node'; +// We re-export these explicitly to ensure rollup doesn't tree-shake them +export { claudeCodeIntegration, createInstrumentedClaudeQuery, patchClaudeCodeQuery }; + +// Force rollup to keep the imports by "using" them +const _forceInclude = { claudeCodeIntegration, createInstrumentedClaudeQuery, patchClaudeCodeQuery }; +if (false as boolean) { console.log(_forceInclude); } export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; From 5f51b5c2567943de9bb3ec1ccd3eac349cdce5e4 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Mon, 20 Oct 2025 07:44:34 -0700 Subject: [PATCH 09/12] feat(core): Add shared GenAI agent attribute constants --- packages/core/src/index.ts | 16 ++++++++++ .../core/src/tracing/ai/gen-ai-attributes.ts | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e18ea294f182..793bd65429fc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -157,6 +157,22 @@ export { instrumentStateGraphCompile, instrumentLangGraph } from './tracing/lang export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants'; export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types'; +export { setTokenUsageAttributes } from './tracing/ai/utils'; +export { + GEN_AI_AGENT_NAME_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + GEN_AI_SYSTEM_ATTRIBUTE, + GEN_AI_TOOL_INPUT_ATTRIBUTE, + GEN_AI_TOOL_NAME_ATTRIBUTE, + GEN_AI_TOOL_OUTPUT_ATTRIBUTE, + GEN_AI_TOOL_TYPE_ATTRIBUTE, +} from './tracing/ai/gen-ai-attributes'; export type { AnthropicAiClient, AnthropicAiOptions, diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index e2808d5f2642..eeea7e54c1c7 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -179,6 +179,35 @@ export const GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE = 'gen_ai.usage.input_to */ export const GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE = 'gen_ai.invoke_agent'; +// ============================================================================= +// AI AGENT ATTRIBUTES +// ============================================================================= + +/** + * The name of the AI agent + */ +export const GEN_AI_AGENT_NAME_ATTRIBUTE = 'gen_ai.agent.name'; + +/** + * The name of the tool being executed + */ +export const GEN_AI_TOOL_NAME_ATTRIBUTE = 'gen_ai.tool.name'; + +/** + * The type of the tool: 'function', 'extension', or 'datastore' + */ +export const GEN_AI_TOOL_TYPE_ATTRIBUTE = 'gen_ai.tool.type'; + +/** + * The input parameters for a tool call + */ +export const GEN_AI_TOOL_INPUT_ATTRIBUTE = 'gen_ai.tool.input'; + +/** + * The output/result of a tool call + */ +export const GEN_AI_TOOL_OUTPUT_ATTRIBUTE = 'gen_ai.tool.output'; + // ============================================================================= // OPENAI-SPECIFIC ATTRIBUTES // ============================================================================= From f62b384fe1c577c403874ed9d8ca89bd9c9ec803 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Mon, 20 Oct 2025 07:51:24 -0700 Subject: [PATCH 10/12] refactor(node): Use shared utilities in Claude Code integration --- packages/nextjs/src/server/index.ts | 4 +- .../tracing/claude-code/helpers.ts | 51 ++-- .../integrations/tracing/claude-code/index.ts | 12 + .../tracing/claude-code/instrumentation.ts | 226 ++++++++++-------- 4 files changed, 175 insertions(+), 118 deletions(-) diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index d32018d4aeb8..35ff6c2ce829 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -50,7 +50,9 @@ export { claudeCodeIntegration, createInstrumentedClaudeQuery, patchClaudeCodeQu // Force rollup to keep the imports by "using" them const _forceInclude = { claudeCodeIntegration, createInstrumentedClaudeQuery, patchClaudeCodeQuery }; -if (false as boolean) { console.log(_forceInclude); } +if (false as boolean) { + console.log(_forceInclude); +} export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; diff --git a/packages/node/src/integrations/tracing/claude-code/helpers.ts b/packages/node/src/integrations/tracing/claude-code/helpers.ts index cddc2d5835dc..7922aa0c9de8 100644 --- a/packages/node/src/integrations/tracing/claude-code/helpers.ts +++ b/packages/node/src/integrations/tracing/claude-code/helpers.ts @@ -1,6 +1,6 @@ import { getClient } from '@sentry/core'; -import { patchClaudeCodeQuery } from './instrumentation'; import type { ClaudeCodeOptions } from './index'; +import { patchClaudeCodeQuery } from './instrumentation'; const CLAUDE_CODE_INTEGRATION_NAME = 'ClaudeCode'; @@ -30,14 +30,14 @@ async function ensurePatchedQuery(): Promise { if (!claudeSDK || typeof claudeSDK.query !== 'function') { throw new Error( - `Failed to find 'query' function in @anthropic-ai/claude-agent-sdk.\n` + - `Make sure you have version >=0.1.0 installed.`, + 'Failed to find \'query\' function in @anthropic-ai/claude-agent-sdk.\n' + + 'Make sure you have version >=0.1.0 installed.', ); } const client = getClient(); const integration = client?.getIntegrationByName(CLAUDE_CODE_INTEGRATION_NAME); - const options = (integration as any)?.options as ClaudeCodeOptions | undefined || {}; + const options = ((integration as any)?.options as ClaudeCodeOptions | undefined) || {}; _globalPatchedQuery = patchClaudeCodeQuery(claudeSDK.query, options); } catch (error) { @@ -45,16 +45,14 @@ async function ensurePatchedQuery(): Promise { _initPromise = null; const errorMessage = - error instanceof Error - ? error.message - : 'Unknown error occurred while loading @anthropic-ai/claude-agent-sdk'; + error instanceof Error ? error.message : 'Unknown error occurred while loading @anthropic-ai/claude-agent-sdk'; throw new Error( `Failed to instrument Claude Code SDK:\n${errorMessage}\n\n` + - `Make sure @anthropic-ai/claude-agent-sdk is installed:\n` + - ` npm install @anthropic-ai/claude-agent-sdk\n` + - ` # or\n` + - ` yarn add @anthropic-ai/claude-agent-sdk`, + 'Make sure @anthropic-ai/claude-agent-sdk is installed:\n' + + ' npm install @anthropic-ai/claude-agent-sdk\n' + + ' # or\n' + + ' yarn add @anthropic-ai/claude-agent-sdk', ); } })(); @@ -72,15 +70,21 @@ async function ensurePatchedQuery(): Promise { * The Claude Code SDK cannot be automatically instrumented due to ESM module * and webpack bundling limitations. * + * @param options - Optional configuration for this specific agent instance + * @param options.name - Custom agent name for differentiation (defaults to 'claude-code') * @returns An instrumented query function ready to use * * @example * ```typescript * import { createInstrumentedClaudeQuery } from '@sentry/node'; - * import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; * + * // Default agent name ('claude-code') * const query = createInstrumentedClaudeQuery(); * + * // Custom agent name for differentiation + * const appBuilder = createInstrumentedClaudeQuery({ name: 'app-builder' }); + * const chatAgent = createInstrumentedClaudeQuery({ name: 'chat-assistant' }); + * * // Use as normal - automatically instrumented! * for await (const message of query({ * prompt: 'Hello', @@ -104,7 +108,11 @@ async function ensurePatchedQuery(): Promise { * }); * ``` */ -export function createInstrumentedClaudeQuery(): (...args: unknown[]) => AsyncGenerator { +export function createInstrumentedClaudeQuery( + options: { name?: string } = {}, +): (...args: unknown[]) => AsyncGenerator { + const agentName = options.name ?? 'claude-code'; + return async function* query(...args: unknown[]): AsyncGenerator { await ensurePatchedQuery(); @@ -112,6 +120,21 @@ export function createInstrumentedClaudeQuery(): (...args: unknown[]) => AsyncGe throw new Error('[Sentry] Failed to initialize instrumented Claude Code query function'); } - yield* _globalPatchedQuery(...args); + // Create a new patched instance with custom agent name + const client = getClient(); + const integration = client?.getIntegrationByName(CLAUDE_CODE_INTEGRATION_NAME); + const integrationOptions = ((integration as any)?.options as ClaudeCodeOptions | undefined) || {}; + + // Import SDK again to get fresh query function + const sdkPath = '@anthropic-ai/claude-agent-sdk'; + const claudeSDK = await import(/* webpackIgnore: true */ sdkPath); + + // Patch with custom agent name + const customPatchedQuery = patchClaudeCodeQuery(claudeSDK.query, { + ...integrationOptions, + agentName, + }); + + yield* customPatchedQuery(...args); }; } diff --git a/packages/node/src/integrations/tracing/claude-code/index.ts b/packages/node/src/integrations/tracing/claude-code/index.ts index ca4009ab0826..f39bc690d36b 100644 --- a/packages/node/src/integrations/tracing/claude-code/index.ts +++ b/packages/node/src/integrations/tracing/claude-code/index.ts @@ -14,6 +14,18 @@ export interface ClaudeCodeOptions { * Defaults to Sentry client's `sendDefaultPii` setting. */ recordOutputs?: boolean; + + /** + * Custom agent name to use for this integration. + * This allows you to differentiate between multiple Claude Code agents in your application. + * Defaults to 'claude-code'. + * + * @example + * ```typescript + * const query = createInstrumentedClaudeQuery({ name: 'app-builder' }); + * ``` + */ + agentName?: string; } const CLAUDE_CODE_INTEGRATION_NAME = 'ClaudeCode'; diff --git a/packages/node/src/integrations/tracing/claude-code/instrumentation.ts b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts index 822fecb2c76d..fe6aa5c0b9e1 100644 --- a/packages/node/src/integrations/tracing/claude-code/instrumentation.ts +++ b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts @@ -1,61 +1,71 @@ import type { Span } from '@opentelemetry/api'; import { + captureException, getClient, + GEN_AI_AGENT_NAME_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + GEN_AI_SYSTEM_ATTRIBUTE, + GEN_AI_TOOL_INPUT_ATTRIBUTE, + GEN_AI_TOOL_NAME_ATTRIBUTE, + GEN_AI_TOOL_OUTPUT_ATTRIBUTE, + GEN_AI_TOOL_TYPE_ATTRIBUTE, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + setTokenUsageAttributes, + startSpan, startSpanManual, withActiveSpan, - startSpan, - captureException, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_OP, } from '@sentry/core'; import type { ClaudeCodeOptions } from './index'; -type ClaudeCodeInstrumentationOptions = ClaudeCodeOptions; - -const GEN_AI_ATTRIBUTES = { - SYSTEM: 'gen_ai.system', - OPERATION_NAME: 'gen_ai.operation.name', - REQUEST_MODEL: 'gen_ai.request.model', - REQUEST_MESSAGES: 'gen_ai.request.messages', - RESPONSE_TEXT: 'gen_ai.response.text', - RESPONSE_TOOL_CALLS: 'gen_ai.response.tool_calls', - RESPONSE_ID: 'gen_ai.response.id', - RESPONSE_MODEL: 'gen_ai.response.model', - USAGE_INPUT_TOKENS: 'gen_ai.usage.input_tokens', - USAGE_OUTPUT_TOKENS: 'gen_ai.usage.output_tokens', - USAGE_TOTAL_TOKENS: 'gen_ai.usage.total_tokens', - TOOL_NAME: 'gen_ai.tool.name', - TOOL_INPUT: 'gen_ai.tool.input', - TOOL_OUTPUT: 'gen_ai.tool.output', - AGENT_NAME: 'gen_ai.agent.name', -} as const; +export type ClaudeCodeInstrumentationOptions = ClaudeCodeOptions; const SENTRY_ORIGIN = 'auto.ai.claude-code'; -function setTokenUsageAttributes( - span: Span, - inputTokens?: number, - outputTokens?: number, - cacheCreationTokens?: number, - cacheReadTokens?: number, -): void { - const attrs: Record = {}; - - if (typeof inputTokens === 'number') { - attrs[GEN_AI_ATTRIBUTES.USAGE_INPUT_TOKENS] = inputTokens; - } - if (typeof outputTokens === 'number') { - attrs[GEN_AI_ATTRIBUTES.USAGE_OUTPUT_TOKENS] = outputTokens; - } - - const total = (inputTokens ?? 0) + (outputTokens ?? 0) + (cacheCreationTokens ?? 0) + (cacheReadTokens ?? 0); - if (total > 0) { - attrs[GEN_AI_ATTRIBUTES.USAGE_TOTAL_TOKENS] = total; - } - - if (Object.keys(attrs).length > 0) { - span.setAttributes(attrs); - } +/** + * Maps Claude Code tool names to OpenTelemetry tool types. + * + * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/ + * @param toolName - The name of the tool (e.g., 'Bash', 'Read', 'WebSearch') + * @returns The OpenTelemetry tool type: 'function', 'extension', or 'datastore' + */ +function getToolType(toolName: string): 'function' | 'extension' | 'datastore' { + // Client-side execution tools - functions that run on the client + const functionTools = new Set([ + 'Bash', + 'BashOutput', + 'KillShell', // Shell/process tools + 'Read', + 'Write', + 'Edit', // File operations + 'Glob', + 'Grep', // File search + 'Task', + 'ExitPlanMode', + 'TodoWrite', // Agent control + 'NotebookEdit', + 'SlashCommand', // Specialized operations + ]); + + // Agent-side API calls - external service integrations + const extensionTools = new Set(['WebSearch', 'WebFetch']); + + // Data access tools - database/structured data operations + // (Currently none in Claude Code, but future-proofing) + const datastoreTools = new Set([]); + + if (functionTools.has(toolName)) return 'function'; + if (extensionTools.has(toolName)) return 'extension'; + if (datastoreTools.has(toolName)) return 'datastore'; + + // Default to function for unknown tools (safest assumption) + return 'function'; } /** @@ -72,21 +82,23 @@ export function patchClaudeCodeQuery( const recordInputs = options.recordInputs ?? defaultPii; const recordOutputs = options.recordOutputs ?? defaultPii; + const agentName = options.agentName ?? 'claude-code'; // Parse query arguments const [queryParams] = args as [Record]; const { options: queryOptions, inputMessages } = queryParams || {}; - const model = (queryOptions as Record)?.model ?? 'sonnet'; + const model = (queryOptions as Record)?.model ?? 'unknown'; // Create original query instance const originalQueryInstance = queryFunction.apply(this, args); // Create instrumented generator - const instrumentedGenerator = _createInstrumentedGenerator( - originalQueryInstance, - model as string, - { recordInputs, recordOutputs, inputMessages }, - ); + const instrumentedGenerator = _createInstrumentedGenerator(originalQueryInstance, model as string, { + recordInputs, + recordOutputs, + inputMessages, + agentName, + }); // Preserve Query interface methods if (typeof (originalQueryInstance as Record).interrupt === 'function') { @@ -112,22 +124,29 @@ export function patchClaudeCodeQuery( function _createInstrumentedGenerator( originalQuery: AsyncGenerator, model: string, - instrumentationOptions: { recordInputs?: boolean; recordOutputs?: boolean; inputMessages?: unknown }, + instrumentationOptions: { + recordInputs?: boolean; + recordOutputs?: boolean; + inputMessages?: unknown; + agentName?: string; + }, ): AsyncGenerator { - return startSpanManual( - { - name: `invoke_agent claude-code`, - op: 'gen_ai.invoke_agent', - attributes: { - [GEN_AI_ATTRIBUTES.SYSTEM]: 'claude-code', - [GEN_AI_ATTRIBUTES.REQUEST_MODEL]: model, - [GEN_AI_ATTRIBUTES.OPERATION_NAME]: 'invoke_agent', - [GEN_AI_ATTRIBUTES.AGENT_NAME]: 'claude-code', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', - }, + const agentName = instrumentationOptions.agentName ?? 'claude-code'; + + return startSpanManual( + { + name: `invoke_agent ${agentName}`, + op: 'gen_ai.invoke_agent', + attributes: { + [GEN_AI_SYSTEM_ATTRIBUTE]: agentName, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: model, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'invoke_agent', + [GEN_AI_AGENT_NAME_ATTRIBUTE]: agentName, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', }, - async function* (span: Span) { + }, + async function* (span: Span) { // State accumulation let sessionId: string | null = null; let currentLLMSpan: Span | null = null; @@ -148,13 +167,9 @@ function _createInstrumentedGenerator( if (msg.type === 'system' && msg.session_id) { sessionId = msg.session_id as string; - if ( - !inputMessagesCaptured && - instrumentationOptions.recordInputs && - msg.conversation_history - ) { + if (!inputMessagesCaptured && instrumentationOptions.recordInputs && msg.conversation_history) { span.setAttributes({ - [GEN_AI_ATTRIBUTES.REQUEST_MESSAGES]: JSON.stringify(msg.conversation_history), + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(msg.conversation_history), }); inputMessagesCaptured = true; } @@ -178,9 +193,9 @@ function _createInstrumentedGenerator( name: `chat ${model}`, op: 'gen_ai.chat', attributes: { - [GEN_AI_ATTRIBUTES.SYSTEM]: 'claude-code', - [GEN_AI_ATTRIBUTES.REQUEST_MODEL]: model, - [GEN_AI_ATTRIBUTES.OPERATION_NAME]: 'chat', + [GEN_AI_SYSTEM_ATTRIBUTE]: agentName, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: model, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', }, @@ -188,9 +203,7 @@ function _createInstrumentedGenerator( (childSpan: Span) => { if (instrumentationOptions.recordInputs && instrumentationOptions.inputMessages) { childSpan.setAttributes({ - [GEN_AI_ATTRIBUTES.REQUEST_MESSAGES]: JSON.stringify( - instrumentationOptions.inputMessages, - ), + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(instrumentationOptions.inputMessages), }); } return childSpan; @@ -206,14 +219,14 @@ function _createInstrumentedGenerator( const content = (msg.message as Record)?.content as unknown[]; if (Array.isArray(content)) { const textContent = content - .filter((c) => (c as Record).type === 'text') - .map((c) => (c as Record).text as string) + .filter(c => (c as Record).type === 'text') + .map(c => (c as Record).text as string) .join(''); if (textContent) { currentTurnContent += textContent; } - const tools = content.filter((c) => (c as Record).type === 'tool_use'); + const tools = content.filter(c => (c as Record).type === 'tool_use'); if (tools.length > 0) { currentTurnTools.push(...tools); } @@ -245,24 +258,24 @@ function _createInstrumentedGenerator( if (currentLLMSpan) { if (instrumentationOptions.recordOutputs && currentTurnContent) { currentLLMSpan.setAttributes({ - [GEN_AI_ATTRIBUTES.RESPONSE_TEXT]: currentTurnContent, + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: currentTurnContent, }); } if (instrumentationOptions.recordOutputs && currentTurnTools.length > 0) { currentLLMSpan.setAttributes({ - [GEN_AI_ATTRIBUTES.RESPONSE_TOOL_CALLS]: JSON.stringify(currentTurnTools), + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(currentTurnTools), }); } if (currentTurnId) { currentLLMSpan.setAttributes({ - [GEN_AI_ATTRIBUTES.RESPONSE_ID]: currentTurnId, + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: currentTurnId, }); } if (currentTurnModel) { currentLLMSpan.setAttributes({ - [GEN_AI_ATTRIBUTES.RESPONSE_MODEL]: currentTurnModel, + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: currentTurnModel, }); } @@ -295,35 +308,39 @@ function _createInstrumentedGenerator( if (msg.type === 'user' && (msg.message as Record)?.content) { const content = (msg.message as Record).content as unknown[]; const toolResults = Array.isArray(content) - ? content.filter((c) => (c as Record).type === 'tool_result') + ? content.filter(c => (c as Record).type === 'tool_result') : []; for (const toolResult of toolResults) { const tr = toolResult as Record; - let matchingTool = currentTurnTools.find( - (t) => (t as Record).id === tr.tool_use_id, - ) as Record | undefined; + let matchingTool = currentTurnTools.find(t => (t as Record).id === tr.tool_use_id) as + | Record + | undefined; let parentLLMSpan = currentLLMSpan; if (!matchingTool && previousTurnTools.length > 0) { - matchingTool = previousTurnTools.find( - (t) => (t as Record).id === tr.tool_use_id, - ) as Record | undefined; + matchingTool = previousTurnTools.find(t => (t as Record).id === tr.tool_use_id) as + | Record + | undefined; parentLLMSpan = previousLLMSpan; } if (matchingTool && parentLLMSpan) { withActiveSpan(parentLLMSpan, () => { + const toolName = matchingTool!.name as string; + const toolType = getToolType(toolName); + startSpan( { - name: `execute_tool ${matchingTool!.name as string}`, + name: `execute_tool ${toolName}`, op: 'gen_ai.execute_tool', attributes: { - [GEN_AI_ATTRIBUTES.SYSTEM]: 'claude-code', - [GEN_AI_ATTRIBUTES.REQUEST_MODEL]: model, - [GEN_AI_ATTRIBUTES.OPERATION_NAME]: 'execute_tool', - [GEN_AI_ATTRIBUTES.AGENT_NAME]: 'claude-code', - [GEN_AI_ATTRIBUTES.TOOL_NAME]: matchingTool!.name as string, + [GEN_AI_SYSTEM_ATTRIBUTE]: agentName, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: model, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'execute_tool', + [GEN_AI_AGENT_NAME_ATTRIBUTE]: agentName, + [GEN_AI_TOOL_NAME_ATTRIBUTE]: toolName, + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: toolType, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.execute_tool', }, @@ -331,19 +348,22 @@ function _createInstrumentedGenerator( (toolSpan: Span) => { if (instrumentationOptions.recordInputs && matchingTool!.input) { toolSpan.setAttributes({ - [GEN_AI_ATTRIBUTES.TOOL_INPUT]: JSON.stringify(matchingTool!.input), + [GEN_AI_TOOL_INPUT_ATTRIBUTE]: JSON.stringify(matchingTool!.input), }); } if (instrumentationOptions.recordOutputs && tr.content) { toolSpan.setAttributes({ - [GEN_AI_ATTRIBUTES.TOOL_OUTPUT]: + [GEN_AI_TOOL_OUTPUT_ATTRIBUTE]: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content), }); } + // Set span status explicitly if (tr.is_error) { toolSpan.setStatus({ code: 2, message: 'Tool execution error' }); + } else { + toolSpan.setStatus({ code: 1 }); // Explicit success status } }, ); @@ -357,13 +377,13 @@ function _createInstrumentedGenerator( if (instrumentationOptions.recordOutputs && finalResult) { span.setAttributes({ - [GEN_AI_ATTRIBUTES.RESPONSE_TEXT]: finalResult, + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: finalResult, }); } if (sessionId) { span.setAttributes({ - [GEN_AI_ATTRIBUTES.RESPONSE_ID]: sessionId, + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: sessionId, }); } From a5b833038e2e30a870bd54cfab22110ad3076cff Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Tue, 25 Nov 2025 23:30:22 -0800 Subject: [PATCH 11/12] refactor(node): Simplify Claude Code integration with OTEL auto-instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OpenTelemetry-based automatic instrumentation via SentryClaudeCodeAgentSdkInstrumentation - Extract ClaudeCodeOptions to dedicated types.ts file - Remove backwards compatibility exports (patchClaudeCodeQuery, createInstrumentedClaudeQuery) - Rename integration to claudeCodeAgentSdkIntegration - Register instrumentation in OTEL preload for automatic patching - Update NextJS re-exports to match simplified API Users now only need: ```typescript Sentry.init({ integrations: [Sentry.claudeCodeAgentSdkIntegration()] }); import { query } from '@anthropic-ai/claude-agent-sdk'; // Auto-instrumented ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/nextjs/src/index.types.ts | 4 +- packages/nextjs/src/server/index.ts | 14 +- packages/node/src/index.ts | 4 +- .../tracing/claude-code/helpers.ts | 140 ------------------ .../integrations/tracing/claude-code/index.ts | 117 ++++++--------- .../tracing/claude-code/instrumentation.ts | 20 +-- .../claude-code/otel-instrumentation.ts | 117 +++++++++++++++ .../integrations/tracing/claude-code/types.ts | 29 ++++ 8 files changed, 214 insertions(+), 231 deletions(-) delete mode 100644 packages/node/src/integrations/tracing/claude-code/helpers.ts create mode 100644 packages/node/src/integrations/tracing/claude-code/otel-instrumentation.ts create mode 100644 packages/node/src/integrations/tracing/claude-code/types.ts diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 92791809e05e..b1cba40834b8 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -28,9 +28,7 @@ export declare const contextLinesIntegration: typeof clientSdk.contextLinesInteg export declare const vercelAIIntegration: typeof serverSdk.vercelAIIntegration; // Claude Code integration (server-only) -export declare const claudeCodeIntegration: typeof serverSdk.claudeCodeIntegration; -export declare const createInstrumentedClaudeQuery: typeof serverSdk.createInstrumentedClaudeQuery; -export declare const patchClaudeCodeQuery: typeof serverSdk.patchClaudeCodeQuery; +export declare const claudeCodeAgentSdkIntegration: typeof serverSdk.claudeCodeAgentSdkIntegration; export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 35ff6c2ce829..8b6dc0f66f9a 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -24,9 +24,7 @@ import { getDefaultIntegrations, httpIntegration, init as nodeInit, - claudeCodeIntegration, - createInstrumentedClaudeQuery, - patchClaudeCodeQuery, + claudeCodeAgentSdkIntegration, } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; @@ -44,12 +42,12 @@ import { handleOnSpanStart } from './handleOnSpanStart'; export * from '@sentry/node'; -// Explicit re-exports for Claude Code integration -// We re-export these explicitly to ensure rollup doesn't tree-shake them -export { claudeCodeIntegration, createInstrumentedClaudeQuery, patchClaudeCodeQuery }; +// Explicit re-export for Claude Code integration +// We re-export this explicitly to ensure rollup doesn't tree-shake it +export { claudeCodeAgentSdkIntegration }; -// Force rollup to keep the imports by "using" them -const _forceInclude = { claudeCodeIntegration, createInstrumentedClaudeQuery, patchClaudeCodeQuery }; +// Force rollup to keep the import by "using" it +const _forceInclude = { claudeCodeAgentSdkIntegration }; if (false as boolean) { console.log(_forceInclude); } diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 2481be129e9c..64f9ec7e0ef5 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -26,8 +26,8 @@ export { amqplibIntegration } from './integrations/tracing/amqplib'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { openAIIntegration } from './integrations/tracing/openai'; export { anthropicAIIntegration } from './integrations/tracing/anthropic-ai'; -export { claudeCodeIntegration, patchClaudeCodeQuery } from './integrations/tracing/claude-code'; -export { createInstrumentedClaudeQuery } from './integrations/tracing/claude-code/helpers'; +export { claudeCodeAgentSdkIntegration } from './integrations/tracing/claude-code'; +export type { ClaudeCodeOptions } from './integrations/tracing/claude-code'; export { googleGenAIIntegration } from './integrations/tracing/google-genai'; export { langChainIntegration } from './integrations/tracing/langchain'; export { langGraphIntegration } from './integrations/tracing/langgraph'; diff --git a/packages/node/src/integrations/tracing/claude-code/helpers.ts b/packages/node/src/integrations/tracing/claude-code/helpers.ts deleted file mode 100644 index 7922aa0c9de8..000000000000 --- a/packages/node/src/integrations/tracing/claude-code/helpers.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { getClient } from '@sentry/core'; -import type { ClaudeCodeOptions } from './index'; -import { patchClaudeCodeQuery } from './instrumentation'; - -const CLAUDE_CODE_INTEGRATION_NAME = 'ClaudeCode'; - -// Global singleton - only patch once per application instance -let _globalPatchedQuery: ((...args: unknown[]) => AsyncGenerator) | null = null; -let _initPromise: Promise | null = null; - -/** - * Lazily loads and patches the Claude Code SDK. - * Ensures only one patched instance exists globally. - */ -async function ensurePatchedQuery(): Promise { - if (_globalPatchedQuery) { - return; - } - - if (_initPromise) { - return _initPromise; - } - - _initPromise = (async () => { - try { - // Use webpackIgnore to prevent webpack from trying to resolve this at build time - // The import resolves at runtime from the user's node_modules - const sdkPath = '@anthropic-ai/claude-agent-sdk'; - const claudeSDK = await import(/* webpackIgnore: true */ sdkPath); - - if (!claudeSDK || typeof claudeSDK.query !== 'function') { - throw new Error( - 'Failed to find \'query\' function in @anthropic-ai/claude-agent-sdk.\n' + - 'Make sure you have version >=0.1.0 installed.', - ); - } - - const client = getClient(); - const integration = client?.getIntegrationByName(CLAUDE_CODE_INTEGRATION_NAME); - const options = ((integration as any)?.options as ClaudeCodeOptions | undefined) || {}; - - _globalPatchedQuery = patchClaudeCodeQuery(claudeSDK.query, options); - } catch (error) { - // Reset state on failure to allow retry on next call - _initPromise = null; - - const errorMessage = - error instanceof Error ? error.message : 'Unknown error occurred while loading @anthropic-ai/claude-agent-sdk'; - - throw new Error( - `Failed to instrument Claude Code SDK:\n${errorMessage}\n\n` + - 'Make sure @anthropic-ai/claude-agent-sdk is installed:\n' + - ' npm install @anthropic-ai/claude-agent-sdk\n' + - ' # or\n' + - ' yarn add @anthropic-ai/claude-agent-sdk', - ); - } - })(); - - return _initPromise; -} - -/** - * Creates a Sentry-instrumented query function for the Claude Code SDK. - * - * This is a convenience helper that reduces boilerplate to a single line. - * The SDK is lazily loaded on first query call, and the patched version is cached globally. - * - * **Important**: This helper is NOT automatic. You must call it in your code. - * The Claude Code SDK cannot be automatically instrumented due to ESM module - * and webpack bundling limitations. - * - * @param options - Optional configuration for this specific agent instance - * @param options.name - Custom agent name for differentiation (defaults to 'claude-code') - * @returns An instrumented query function ready to use - * - * @example - * ```typescript - * import { createInstrumentedClaudeQuery } from '@sentry/node'; - * - * // Default agent name ('claude-code') - * const query = createInstrumentedClaudeQuery(); - * - * // Custom agent name for differentiation - * const appBuilder = createInstrumentedClaudeQuery({ name: 'app-builder' }); - * const chatAgent = createInstrumentedClaudeQuery({ name: 'chat-assistant' }); - * - * // Use as normal - automatically instrumented! - * for await (const message of query({ - * prompt: 'Hello', - * options: { model: 'claude-sonnet-4-5' } - * })) { - * console.log(message); - * } - * ``` - * - * Configuration is automatically pulled from your `claudeCodeIntegration()` setup: - * - * @example - * ```typescript - * Sentry.init({ - * integrations: [ - * Sentry.claudeCodeIntegration({ - * recordInputs: true, // These options are used - * recordOutputs: true, // by createInstrumentedClaudeQuery() - * }) - * ] - * }); - * ``` - */ -export function createInstrumentedClaudeQuery( - options: { name?: string } = {}, -): (...args: unknown[]) => AsyncGenerator { - const agentName = options.name ?? 'claude-code'; - - return async function* query(...args: unknown[]): AsyncGenerator { - await ensurePatchedQuery(); - - if (!_globalPatchedQuery) { - throw new Error('[Sentry] Failed to initialize instrumented Claude Code query function'); - } - - // Create a new patched instance with custom agent name - const client = getClient(); - const integration = client?.getIntegrationByName(CLAUDE_CODE_INTEGRATION_NAME); - const integrationOptions = ((integration as any)?.options as ClaudeCodeOptions | undefined) || {}; - - // Import SDK again to get fresh query function - const sdkPath = '@anthropic-ai/claude-agent-sdk'; - const claudeSDK = await import(/* webpackIgnore: true */ sdkPath); - - // Patch with custom agent name - const customPatchedQuery = patchClaudeCodeQuery(claudeSDK.query, { - ...integrationOptions, - agentName, - }); - - yield* customPatchedQuery(...args); - }; -} diff --git a/packages/node/src/integrations/tracing/claude-code/index.ts b/packages/node/src/integrations/tracing/claude-code/index.ts index f39bc690d36b..0dca91f592ed 100644 --- a/packages/node/src/integrations/tracing/claude-code/index.ts +++ b/packages/node/src/integrations/tracing/claude-code/index.ts @@ -1,94 +1,81 @@ import type { IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; -import { patchClaudeCodeQuery } from './instrumentation'; +import { generateInstrumentOnce } from '@sentry/node-core'; +import { SentryClaudeCodeAgentSdkInstrumentation } from './otel-instrumentation'; +import type { ClaudeCodeOptions } from './types'; -export interface ClaudeCodeOptions { - /** - * Whether to record prompt messages. - * Defaults to Sentry client's `sendDefaultPii` setting. - */ - recordInputs?: boolean; +export type { ClaudeCodeOptions } from './types'; - /** - * Whether to record response text, tool calls, and tool outputs. - * Defaults to Sentry client's `sendDefaultPii` setting. - */ - recordOutputs?: boolean; +export const CLAUDE_CODE_AGENT_SDK_INTEGRATION_NAME = 'ClaudeCodeAgentSdk'; - /** - * Custom agent name to use for this integration. - * This allows you to differentiate between multiple Claude Code agents in your application. - * Defaults to 'claude-code'. - * - * @example - * ```typescript - * const query = createInstrumentedClaudeQuery({ name: 'app-builder' }); - * ``` - */ - agentName?: string; -} - -const CLAUDE_CODE_INTEGRATION_NAME = 'ClaudeCode'; +/** + * Instruments the Claude Code Agent SDK using OpenTelemetry. + * This is called automatically when the integration is added to Sentry. + */ +export const instrumentClaudeCodeAgentSdk = generateInstrumentOnce( + CLAUDE_CODE_AGENT_SDK_INTEGRATION_NAME, + options => new SentryClaudeCodeAgentSdkInstrumentation(options), +); -const _claudeCodeIntegration = ((options: ClaudeCodeOptions = {}) => { +const _claudeCodeAgentSdkIntegration = ((options: ClaudeCodeOptions = {}) => { return { - name: CLAUDE_CODE_INTEGRATION_NAME, + name: CLAUDE_CODE_AGENT_SDK_INTEGRATION_NAME, options, setupOnce() { - // Note: Automatic patching via require hooks doesn't work for ESM modules - // or webpack-bundled dependencies. Users must manually patch using patchClaudeCodeQuery() - // in their route files. + instrumentClaudeCodeAgentSdk(options); }, }; }) satisfies IntegrationFn; /** - * Adds Sentry tracing instrumentation for the Claude Code SDK. + * Adds Sentry tracing instrumentation for the Claude Code Agent SDK. * - * **Important**: Due to ESM module and bundler limitations, this integration requires - * using the `createInstrumentedClaudeQuery()` helper function in your code. - * See the example below for proper usage. + * This integration automatically instruments the `query` function from + * `@anthropic-ai/claude-agent-sdk` to capture telemetry data following + * OpenTelemetry Semantic Conventions for Generative AI. * - * This integration captures telemetry data following OpenTelemetry Semantic Conventions - * for Generative AI, including: - * - Agent invocation spans (`invoke_agent`) - * - LLM chat spans (`chat`) - * - Tool execution spans (`execute_tool`) - * - Token usage, model info, and session tracking + * **Important**: Sentry must be initialized BEFORE importing `@anthropic-ai/claude-agent-sdk`. * * @example * ```typescript - * // Step 1: Configure the integration + * // Initialize Sentry FIRST * import * as Sentry from '@sentry/node'; * * Sentry.init({ * dsn: 'your-dsn', * integrations: [ - * Sentry.claudeCodeIntegration({ + * Sentry.claudeCodeAgentSdkIntegration({ * recordInputs: true, * recordOutputs: true * }) * ], * }); * - * // Step 2: Use the helper in your routes - * import { createInstrumentedClaudeQuery } from '@sentry/node'; - * - * const query = createInstrumentedClaudeQuery(); + * // THEN import the SDK - it will be automatically instrumented! + * import { query } from '@anthropic-ai/claude-agent-sdk'; * - * // Use query as normal - automatically instrumented! + * // Use query as normal - spans are created automatically * for await (const message of query({ * prompt: 'Hello', - * options: { model: 'claude-sonnet-4-5' } + * options: { model: 'claude-sonnet-4-20250514' } * })) { * console.log(message); * } * ``` * + * ## Captured Telemetry + * + * This integration captures: + * - Agent invocation spans (`gen_ai.invoke_agent`) + * - LLM chat spans (`gen_ai.chat`) + * - Tool execution spans (`gen_ai.execute_tool`) + * - Token usage, model info, and session tracking + * * ## Options * * - `recordInputs`: Whether to record prompt messages (default: respects `sendDefaultPii` client option) * - `recordOutputs`: Whether to record response text, tool calls, and outputs (default: respects `sendDefaultPii` client option) + * - `agentName`: Custom agent name for differentiation (default: 'claude-code') * * ### Default Behavior * @@ -101,7 +88,7 @@ const _claudeCodeIntegration = ((options: ClaudeCodeOptions = {}) => { * // Record inputs and outputs when sendDefaultPii is false * Sentry.init({ * integrations: [ - * Sentry.claudeCodeIntegration({ + * Sentry.claudeCodeAgentSdkIntegration({ * recordInputs: true, * recordOutputs: true * }) @@ -112,31 +99,23 @@ const _claudeCodeIntegration = ((options: ClaudeCodeOptions = {}) => { * Sentry.init({ * sendDefaultPii: true, * integrations: [ - * Sentry.claudeCodeIntegration({ + * Sentry.claudeCodeAgentSdkIntegration({ * recordInputs: false, * recordOutputs: false * }) * ], * }); + * + * // Custom agent name + * Sentry.init({ + * integrations: [ + * Sentry.claudeCodeAgentSdkIntegration({ + * agentName: 'my-coding-assistant' + * }) + * ], + * }); * ``` * * @see https://docs.sentry.io/platforms/javascript/guides/node/ai-monitoring/ */ -export const claudeCodeIntegration = defineIntegration(_claudeCodeIntegration); - -/** - * Manually patch the Claude Code SDK query function with Sentry instrumentation. - * - * **Note**: Most users should use `createInstrumentedClaudeQuery()` instead, - * which is simpler and handles option retrieval automatically. - * - * This low-level function is exported for advanced use cases where you need - * explicit control over the patching process. - * - * @param queryFunction - The original query function from @anthropic-ai/claude-agent-sdk - * @param options - Instrumentation options (recordInputs, recordOutputs) - * @returns Instrumented query function - * - * @see createInstrumentedClaudeQuery for the recommended high-level helper - */ -export { patchClaudeCodeQuery }; +export const claudeCodeAgentSdkIntegration = defineIntegration(_claudeCodeAgentSdkIntegration); diff --git a/packages/node/src/integrations/tracing/claude-code/instrumentation.ts b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts index fe6aa5c0b9e1..2c1557aea689 100644 --- a/packages/node/src/integrations/tracing/claude-code/instrumentation.ts +++ b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts @@ -1,7 +1,7 @@ +/* eslint-disable max-lines */ import type { Span } from '@opentelemetry/api'; import { captureException, - getClient, GEN_AI_AGENT_NAME_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, @@ -15,6 +15,7 @@ import { GEN_AI_TOOL_NAME_ATTRIBUTE, GEN_AI_TOOL_OUTPUT_ATTRIBUTE, GEN_AI_TOOL_TYPE_ATTRIBUTE, + getClient, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setTokenUsageAttributes, @@ -22,7 +23,7 @@ import { startSpanManual, withActiveSpan, } from '@sentry/core'; -import type { ClaudeCodeOptions } from './index'; +import type { ClaudeCodeOptions } from './types'; export type ClaudeCodeInstrumentationOptions = ClaudeCodeOptions; @@ -103,12 +104,12 @@ export function patchClaudeCodeQuery( // Preserve Query interface methods if (typeof (originalQueryInstance as Record).interrupt === 'function') { (instrumentedGenerator as unknown as Record).interrupt = ( - (originalQueryInstance as Record).interrupt as Function + (originalQueryInstance as Record).interrupt as (...args: unknown[]) => unknown ).bind(originalQueryInstance); } if (typeof (originalQueryInstance as Record).setPermissionMode === 'function') { (instrumentedGenerator as unknown as Record).setPermissionMode = ( - (originalQueryInstance as Record).setPermissionMode as Function + (originalQueryInstance as Record).setPermissionMode as (...args: unknown[]) => unknown ).bind(originalQueryInstance); } @@ -146,6 +147,7 @@ function _createInstrumentedGenerator( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', }, }, + // eslint-disable-next-line complexity async function* (span: Span) { // State accumulation let sessionId: string | null = null; @@ -327,7 +329,7 @@ function _createInstrumentedGenerator( if (matchingTool && parentLLMSpan) { withActiveSpan(parentLLMSpan, () => { - const toolName = matchingTool!.name as string; + const toolName = matchingTool.name as string; const toolType = getToolType(toolName); startSpan( @@ -346,9 +348,9 @@ function _createInstrumentedGenerator( }, }, (toolSpan: Span) => { - if (instrumentationOptions.recordInputs && matchingTool!.input) { + if (instrumentationOptions.recordInputs && matchingTool.input) { toolSpan.setAttributes({ - [GEN_AI_TOOL_INPUT_ATTRIBUTE]: JSON.stringify(matchingTool!.input), + [GEN_AI_TOOL_INPUT_ATTRIBUTE]: JSON.stringify(matchingTool.input), }); } @@ -401,12 +403,12 @@ function _createInstrumentedGenerator( throw error; } finally { // Ensure all child spans are closed even if generator exits early - if (currentLLMSpan && currentLLMSpan.isRecording()) { + if (currentLLMSpan?.isRecording()) { currentLLMSpan.setStatus({ code: 1 }); currentLLMSpan.end(); } - if (previousLLMSpan && previousLLMSpan.isRecording()) { + if (previousLLMSpan?.isRecording()) { previousLLMSpan.setStatus({ code: 1 }); previousLLMSpan.end(); } diff --git a/packages/node/src/integrations/tracing/claude-code/otel-instrumentation.ts b/packages/node/src/integrations/tracing/claude-code/otel-instrumentation.ts new file mode 100644 index 000000000000..9abacadcd59e --- /dev/null +++ b/packages/node/src/integrations/tracing/claude-code/otel-instrumentation.ts @@ -0,0 +1,117 @@ +import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; +import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import { getClient, SDK_VERSION } from '@sentry/core'; +import { patchClaudeCodeQuery } from './instrumentation'; +import type { ClaudeCodeOptions } from './types'; + +const SUPPORTED_VERSIONS = ['>=0.1.0 <1.0.0']; + +type ClaudeCodeInstrumentationConfig = InstrumentationConfig & ClaudeCodeOptions; + +/** + * Represents the shape of the @anthropic-ai/claude-agent-sdk module exports. + */ +interface ClaudeAgentSdkModuleExports { + [key: string]: unknown; + query: (...args: unknown[]) => AsyncGenerator; +} + +/** + * OpenTelemetry instrumentation for the Claude Code Agent SDK. + * + * This instrumentation automatically patches the `query` function from + * `@anthropic-ai/claude-agent-sdk` to add Sentry tracing spans. + * + * It handles both ESM and CommonJS module formats. + */ +export class SentryClaudeCodeAgentSdkInstrumentation extends InstrumentationBase { + public constructor(config: ClaudeCodeInstrumentationConfig = {}) { + super('@sentry/instrumentation-claude-code-agent-sdk', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation by defining the module to be patched. + */ + public init(): InstrumentationModuleDefinition { + return new InstrumentationNodeModuleDefinition( + '@anthropic-ai/claude-agent-sdk', + SUPPORTED_VERSIONS, + this._patch.bind(this), + ); + } + + /** + * Patches the module exports to wrap the query function with instrumentation. + */ + private _patch(moduleExports: ClaudeAgentSdkModuleExports): ClaudeAgentSdkModuleExports { + const config = this.getConfig(); + const originalQuery = moduleExports.query; + + if (typeof originalQuery !== 'function') { + this._diag.warn('Could not find query function in @anthropic-ai/claude-agent-sdk'); + return moduleExports; + } + + // Create wrapped query function + const wrappedQuery = function ( + this: unknown, + ...args: unknown[] + ): AsyncGenerator { + const client = getClient(); + const defaultPii = Boolean(client?.getOptions().sendDefaultPii); + + const options: ClaudeCodeOptions = { + recordInputs: config.recordInputs ?? defaultPii, + recordOutputs: config.recordOutputs ?? defaultPii, + agentName: config.agentName ?? 'claude-code', + }; + + // Use the existing patch logic + const instrumentedQuery = patchClaudeCodeQuery(originalQuery, options); + return instrumentedQuery.apply(this, args); + }; + + // Preserve function properties + Object.defineProperty(wrappedQuery, 'name', { value: originalQuery.name }); + Object.defineProperty(wrappedQuery, 'length', { value: originalQuery.length }); + + // Check if ESM module namespace object + // https://tc39.es/ecma262/#sec-module-namespace-objects + if (Object.prototype.toString.call(moduleExports) === '[object Module]') { + // ESM: Replace query export directly + // OTel's instrumentation makes these writable + try { + moduleExports.query = wrappedQuery; + } catch { + // If direct assignment fails, try defineProperty + Object.defineProperty(moduleExports, 'query', { + value: wrappedQuery, + writable: true, + configurable: true, + enumerable: true, + }); + } + + // Also patch default export if it has a query property + if ( + moduleExports.default && + typeof moduleExports.default === 'object' && + 'query' in moduleExports.default + ) { + try { + (moduleExports.default as Record).query = wrappedQuery; + } catch { + // Ignore if we can't patch default - this is expected in some cases + } + } + + return moduleExports; + } else { + // CJS: Return new object with patched query spread over original + return { + ...moduleExports, + query: wrappedQuery, + }; + } + } +} diff --git a/packages/node/src/integrations/tracing/claude-code/types.ts b/packages/node/src/integrations/tracing/claude-code/types.ts new file mode 100644 index 000000000000..10cba75f9183 --- /dev/null +++ b/packages/node/src/integrations/tracing/claude-code/types.ts @@ -0,0 +1,29 @@ +export interface ClaudeCodeOptions { + /** + * Whether to record prompt messages. + * Defaults to Sentry client's `sendDefaultPii` setting. + */ + recordInputs?: boolean; + + /** + * Whether to record response text, tool calls, and tool outputs. + * Defaults to Sentry client's `sendDefaultPii` setting. + */ + recordOutputs?: boolean; + + /** + * Custom agent name to use for this integration. + * This allows you to differentiate between multiple Claude Code agents in your application. + * Defaults to 'claude-code'. + * + * @example + * ```typescript + * Sentry.init({ + * integrations: [ + * Sentry.claudeCodeAgentSdkIntegration({ agentName: 'app-builder' }) + * ] + * }); + * ``` + */ + agentName?: string; +} From 3f69bfd76c512dadf116489cb624f091ee489196 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Sat, 27 Dec 2025 22:13:00 -0800 Subject: [PATCH 12/12] feat(node): Enhance Claude Code instrumentation with OpenTelemetry spec compliance - Fix GEN_AI_SYSTEM_ATTRIBUTE to use 'anthropic' per OpenTelemetry semantic conventions - Add GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE for capturing available tools from system init - Add GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE for tracking stop_reason - Use getTruncatedJsonString for proper payload truncation in span attributes - Expand tool categorization with new tools (KillBash, EnterPlanMode, AskUserQuestion, Skill, MCP tools) - Add better error metadata with function name in mechanism data - Export patchClaudeCodeQuery for manual instrumentation use cases - Add comprehensive integration tests for Claude Code Agent SDK instrumentation --- .../claude-code/instrument-with-options.mjs | 20 + .../claude-code/instrument-with-pii.mjs | 14 + .../suites/tracing/claude-code/instrument.mjs | 14 + .../tracing/claude-code/mock-server.mjs | 514 +++++++++++++++++ .../tracing/claude-code/scenario-errors.mjs | 88 +++ .../tracing/claude-code/scenario-simple.mjs | 86 +++ .../tracing/claude-code/scenario-tools.mjs | 72 +++ .../suites/tracing/claude-code/scenario.mjs | 126 +++++ .../suites/tracing/claude-code/test-simple.ts | 22 + .../suites/tracing/claude-code/test.ts | 197 +++++++ packages/core/src/index.ts | 4 +- .../core/src/tracing/ai/gen-ai-attributes.ts | 5 - packages/nextjs/src/server/index.ts | 13 +- packages/node/src/index.ts | 2 +- .../integrations/tracing/claude-code/index.ts | 3 + .../tracing/claude-code/instrumentation.ts | 522 ++++++++++-------- .../claude-code/otel-instrumentation.ts | 11 +- 17 files changed, 1444 insertions(+), 269 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-options.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-pii.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/claude-code/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/claude-code/mock-server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-errors.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-simple.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-tools.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/claude-code/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/claude-code/test-simple.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/claude-code/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-options.mjs new file mode 100644 index 000000000000..3c45fed6332e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-options.mjs @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Configuration with custom options that override sendDefaultPii +// sendDefaultPii: false, but recordInputs: true, recordOutputs: false +// This means input messages SHOULD be recorded, but NOT output text + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [ + Sentry.claudeCodeAgentSdkIntegration({ + recordInputs: true, + recordOutputs: false, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-pii.mjs new file mode 100644 index 000000000000..36c81e86afae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-pii.mjs @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Configuration with PII enabled +// This means input messages and output text SHOULD be recorded + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [Sentry.claudeCodeAgentSdkIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument.mjs new file mode 100644 index 000000000000..7aea43214263 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument.mjs @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Default configuration: sendDefaultPii: false +// This means NO input messages or output text should be recorded by default + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [Sentry.claudeCodeAgentSdkIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/mock-server.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/mock-server.mjs new file mode 100644 index 000000000000..ba5181f7cbc9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/mock-server.mjs @@ -0,0 +1,514 @@ +/* eslint-disable no-console, max-lines */ +/** + * Mock implementation of @anthropic-ai/claude-agent-sdk + * Simulates the query function behavior for testing + * + * Message format matches the real Claude Agent SDK: + * - type: 'system' - Session initialization + * - type: 'assistant' - LLM responses + * - type: 'user' - Tool results + * - type: 'result' - Final result + */ + +export class MockClaudeAgentSdk { + constructor(scenarios = {}) { + this.scenarios = scenarios; + } + + /** + * Mock query function that returns an AsyncGenerator + * @param {Object} params - Query parameters + * @param {string} params.prompt - The prompt text + * @param {Object} params.options - Query options + * @param {string} params.options.model - Model to use + * @param {Array} params.inputMessages - Previous conversation messages + */ + query(params) { + const generator = this._createGenerator(params); + + // Preserve special methods that Claude Code SDK provides + generator.interrupt = () => { + console.log('[Mock] interrupt() called'); + }; + + generator.setPermissionMode = mode => { + console.log('[Mock] setPermissionMode() called with:', mode); + }; + + return generator; + } + + async *_createGenerator(params) { + const model = params.options?.model || 'claude-sonnet-4-20250514'; + const sessionId = `sess_${Math.random().toString(36).substr(2, 9)}`; + const scenarioName = params.options?.scenario || 'basic'; + + // Get scenario or use default + const scenario = this.scenarios[scenarioName] || this._getBasicScenario(params); + + // Yield messages with small delays to simulate streaming + for (const message of scenario.messages) { + // Add small delay to simulate network + await new Promise(resolve => setTimeout(resolve, message.delay || 5)); + + // Inject session info and model where appropriate + if (message.type === 'system') { + yield { ...message, session_id: sessionId, model }; + } else if (message.type === 'assistant' && message.message) { + // Inject model into assistant message if not present + const messageData = message.message; + if (!messageData.model) { + messageData.model = model; + } + yield message; + } else { + yield message; + } + } + } + + _getBasicScenario(params) { + const responseId = `resp_${Date.now()}`; + const usage = { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 5, + cache_read_input_tokens: 3, + }; + + return { + messages: [ + // Session initialization + { + type: 'system', + session_id: 'will-be-replaced', + model: 'will-be-replaced', + conversation_history: params.inputMessages || [], + }, + // Assistant response + { + type: 'assistant', + message: { + id: responseId, + model: 'will-be-replaced', + role: 'assistant', + content: [{ type: 'text', text: 'I can help you with that.' }], + stop_reason: 'end_turn', + usage, + }, + }, + // Final result (includes usage for final tallying) + { + type: 'result', + result: 'I can help you with that.', + usage, + }, + ], + }; + } +} + +/** + * Predefined scenarios for different test cases + */ +export const SCENARIOS = { + basic: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + { + type: 'assistant', + message: { + id: 'resp_basic_123', + role: 'assistant', + content: [{ type: 'text', text: 'Hello! How can I help you today?' }], + stop_reason: 'end_turn', + usage: { + input_tokens: 15, + output_tokens: 25, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 10, + }, + }, + }, + { + type: 'result', + result: 'Hello! How can I help you today?', + usage: { + input_tokens: 15, + output_tokens: 25, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 10, + }, + }, + ], + }, + + withTools: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + // First LLM turn - makes a tool call + { + type: 'assistant', + message: { + id: 'resp_tool_1', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me read that file for you.' }, + { + type: 'tool_use', + id: 'tool_read_1', + name: 'Read', + input: { file_path: '/test.txt' }, + }, + ], + stop_reason: 'tool_use', + usage: { + input_tokens: 20, + output_tokens: 15, + cache_creation_input_tokens: 5, + cache_read_input_tokens: 0, + }, + }, + }, + // Tool result comes back + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_read_1', + content: 'File contents: Hello World', + }, + ], + }, + }, + // Second LLM turn - processes tool result + { + type: 'assistant', + message: { + id: 'resp_tool_2', + role: 'assistant', + content: [{ type: 'text', text: 'The file contains "Hello World".' }], + stop_reason: 'end_turn', + usage: { + input_tokens: 30, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 15, + }, + }, + }, + { + type: 'result', + result: 'The file contains "Hello World".', + usage: { + input_tokens: 50, // Cumulative: 20 + 30 + output_tokens: 35, // Cumulative: 15 + 20 + cache_creation_input_tokens: 5, + cache_read_input_tokens: 15, + }, + }, + ], + }, + + multipleTools: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + // First tool call - Glob + { + type: 'assistant', + message: { + id: 'resp_multi_1', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me find the JavaScript files.' }, + { + type: 'tool_use', + id: 'tool_glob_1', + name: 'Glob', + input: { pattern: '*.js' }, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 10, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_glob_1', + content: 'test.js\nindex.js', + }, + ], + }, + }, + // Second tool call - Read + { + type: 'assistant', + message: { + id: 'resp_multi_2', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me read the first file.' }, + { + type: 'tool_use', + id: 'tool_read_1', + name: 'Read', + input: { file_path: 'test.js' }, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 15, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 5 }, + }, + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_read_1', + content: 'console.log("test")', + }, + ], + }, + }, + // Final response + { + type: 'assistant', + message: { + id: 'resp_multi_3', + role: 'assistant', + content: [{ type: 'text', text: 'Found 2 JavaScript files.' }], + stop_reason: 'end_turn', + usage: { input_tokens: 20, output_tokens: 15, cache_creation_input_tokens: 0, cache_read_input_tokens: 10 }, + }, + }, + { + type: 'result', + result: 'Found 2 JavaScript files.', + usage: { + input_tokens: 45, // Cumulative: 10 + 15 + 20 + output_tokens: 35, // Cumulative: 10 + 10 + 15 + cache_creation_input_tokens: 0, + cache_read_input_tokens: 15, // Cumulative: 0 + 5 + 10 + }, + }, + ], + }, + + extensionTools: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + { + type: 'assistant', + message: { + id: 'resp_ext_1', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me search for that.' }, + { + type: 'tool_use', + id: 'tool_search_1', + name: 'WebSearch', + input: { query: 'Sentry error tracking' }, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 15, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_search_1', + content: 'Found 3 results about Sentry', + }, + ], + }, + }, + { + type: 'assistant', + message: { + id: 'resp_ext_2', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me fetch the main page.' }, + { + type: 'tool_use', + id: 'tool_fetch_1', + name: 'WebFetch', + input: { url: 'https://sentry.io' }, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 20, output_tokens: 15, cache_creation_input_tokens: 0, cache_read_input_tokens: 5 }, + }, + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_fetch_1', + content: '...', + }, + ], + }, + }, + { + type: 'assistant', + message: { + id: 'resp_ext_3', + role: 'assistant', + content: [{ type: 'text', text: 'Sentry is an error tracking platform.' }], + stop_reason: 'end_turn', + usage: { input_tokens: 25, output_tokens: 20, cache_creation_input_tokens: 0, cache_read_input_tokens: 10 }, + }, + }, + { + type: 'result', + result: 'Sentry is an error tracking platform.', + usage: { + input_tokens: 60, // Cumulative: 15 + 20 + 25 + output_tokens: 45, // Cumulative: 10 + 15 + 20 + cache_creation_input_tokens: 0, + cache_read_input_tokens: 15, // Cumulative: 0 + 5 + 10 + }, + }, + ], + }, + + agentError: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + // Error during agent operation + { + type: 'error', + error: new Error('Agent initialization failed'), + code: 'AGENT_INIT_ERROR', + delay: 10, + }, + ], + }, + + llmError: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + // Error during LLM call + { + type: 'error', + error: new Error('Rate limit exceeded'), + code: 'RATE_LIMIT_ERROR', + statusCode: 429, + delay: 10, + }, + ], + }, + + toolError: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + { + type: 'assistant', + message: { + id: 'resp_tool_err_1', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me run that command.' }, + { + type: 'tool_use', + id: 'tool_bash_1', + name: 'Bash', + input: { command: 'invalid_command' }, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 10, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_bash_1', + content: 'Command not found: invalid_command', + is_error: true, + }, + ], + }, + }, + { + type: 'assistant', + message: { + id: 'resp_tool_err_2', + role: 'assistant', + content: [{ type: 'text', text: 'The command failed to execute.' }], + stop_reason: 'end_turn', + usage: { input_tokens: 15, output_tokens: 15, cache_creation_input_tokens: 0, cache_read_input_tokens: 5 }, + }, + }, + { + type: 'result', + result: 'The command failed to execute.', + usage: { + input_tokens: 25, // Cumulative: 10 + 15 + output_tokens: 25, // Cumulative: 10 + 15 + cache_creation_input_tokens: 0, + cache_read_input_tokens: 5, + }, + }, + ], + }, +}; + +/** + * Helper to create a mock SDK instance with predefined scenarios + */ +export function createMockSdk() { + return new MockClaudeAgentSdk(SCENARIOS); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-errors.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-errors.mjs new file mode 100644 index 000000000000..8adb5b693c0e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-errors.mjs @@ -0,0 +1,88 @@ +/* eslint-disable no-console */ +import * as Sentry from '@sentry/node'; +import { createMockSdk } from './mock-server.mjs'; + +// This scenario tests error handling: +// - Agent initialization errors +// - LLM errors (rate limits, API errors) +// - Tool execution errors +// - Error span attributes and status + +async function run() { + const mockSdk = createMockSdk(); + + // Test agent initialization error + console.log('[Test] Running agent initialization error...'); + try { + const query1 = mockSdk.query({ + prompt: 'This will fail at agent init', + options: { model: 'claude-sonnet-4-20250514', scenario: 'agentError' }, + }); + + for await (const message of query1) { + console.log('[Message]', message.type); + if (message.type === 'error') { + throw message.error; + } + } + } catch (error) { + console.log('[Error caught]', error.message); + console.log('[Test] Agent error handled\n'); + } + + // Test LLM error (rate limit) + console.log('[Test] Running LLM error (rate limit)...'); + try { + const query2 = mockSdk.query({ + prompt: 'This will fail during LLM call', + options: { model: 'claude-sonnet-4-20250514', scenario: 'llmError' }, + }); + + for await (const message of query2) { + console.log('[Message]', message.type); + if (message.type === 'error') { + console.log('[Error details]', { + message: message.error.message, + code: message.code, + statusCode: message.statusCode, + }); + throw message.error; + } + } + } catch (error) { + console.log('[Error caught]', error.message); + console.log('[Test] LLM error handled\n'); + } + + // Test tool execution error + console.log('[Test] Running tool execution error...'); + const query3 = mockSdk.query({ + prompt: 'Run a command that will fail', + options: { model: 'claude-sonnet-4-20250514', scenario: 'toolError' }, + }); + + let toolErrorSeen = false; + for await (const message of query3) { + console.log('[Message]', message.type); + if (message.type === 'tool_result' && message.status === 'error') { + console.log('[Tool Error]', message.toolName, '-', message.error); + toolErrorSeen = true; + } else if (message.type === 'agent_complete') { + console.log('[Agent Complete]', message.result); + } + } + + if (toolErrorSeen) { + console.log('[Test] Tool error recorded successfully'); + } + console.log('[Test] Tool error scenario complete\n'); + + // Allow spans to be sent + await Sentry.flush(2000); + console.log('[Test] All error scenarios complete'); +} + +run().catch(error => { + console.error('[Fatal error]', error); + process.exit(1); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-simple.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-simple.mjs new file mode 100644 index 000000000000..08497a0e5188 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-simple.mjs @@ -0,0 +1,86 @@ +/* eslint-disable no-console */ +import { patchClaudeCodeQuery } from '@sentry/node'; +import * as Sentry from '@sentry/node'; + +// Very simple scenario to debug test infrastructure +// Uses correct Claude Agent SDK message format + +class SimpleMockSdk { + async *query(params) { + console.log('[Mock] Query called with model:', params.options?.model); + + const usage = { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }; + + // System message with session ID + yield { + type: 'system', + session_id: 'sess_test123', + model: 'claude-sonnet-4-20250514', + conversation_history: params.inputMessages || [], + }; + + // Small delay + await new Promise(resolve => setTimeout(resolve, 10)); + + // Assistant message (LLM response) + yield { + type: 'assistant', + message: { + id: 'resp_test456', + model: 'claude-sonnet-4-20250514', + role: 'assistant', + content: [{ type: 'text', text: 'Test response' }], + stop_reason: 'end_turn', + usage, + }, + }; + + // Result message (includes usage for final stats) + yield { type: 'result', result: 'Test response', usage }; + + console.log('[Mock] Query generator complete'); + } +} + +async function run() { + console.log('[Test] Starting simple scenario...'); + + const mockSdk = new SimpleMockSdk(); + const originalQuery = mockSdk.query.bind(mockSdk); + + console.log('[Test] Patching query function...'); + const patchedQuery = patchClaudeCodeQuery(originalQuery, { + agentName: 'claude-code', + }); + + console.log('[Test] Running patched query...'); + const query = patchedQuery({ + prompt: 'Test', + inputMessages: [{ role: 'user', content: 'Test' }], + options: { model: 'claude-sonnet-4-20250514' }, + }); + + let messageCount = 0; + for await (const message of query) { + messageCount++; + console.log(`[Test] Message ${messageCount}:`, message.type); + } + + console.log(`[Test] Received ${messageCount} messages`); + console.log('[Test] Flushing Sentry...'); + + await Sentry.flush(2000); + + console.log('[Test] Complete'); +} + +run().catch(error => { + console.error('[Fatal error]', error); + console.error(error.stack); + process.exit(1); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-tools.mjs new file mode 100644 index 000000000000..49f45bd44d45 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-tools.mjs @@ -0,0 +1,72 @@ +/* eslint-disable no-console */ +import * as Sentry from '@sentry/node'; +import { createMockSdk } from './mock-server.mjs'; + +// This scenario specifically tests tool execution: +// - Function tools (Read, Bash, Glob, etc.) +// - Extension tools (WebSearch, WebFetch) +// - Tool input/output recording +// - Tool type classification + +async function run() { + const mockSdk = createMockSdk(); + + // Test function tools + console.log('[Test] Running with function tools (Read)...'); + const query1 = mockSdk.query({ + prompt: 'Read the file', + options: { model: 'claude-sonnet-4-20250514', scenario: 'withTools' }, + }); + + for await (const message of query1) { + if (message.type === 'llm_tool_call') { + console.log('[Tool Call]', message.toolName, '- Type: function'); + } else if (message.type === 'tool_result') { + console.log('[Tool Result]', message.toolName, '- Status:', message.status); + } + } + + console.log('[Test] Function tools complete\n'); + + // Test multiple tools in sequence + console.log('[Test] Running with multiple tools...'); + const query2 = mockSdk.query({ + prompt: 'Find and read JavaScript files', + options: { model: 'claude-sonnet-4-20250514', scenario: 'multipleTools' }, + }); + + const toolCalls = []; + for await (const message of query2) { + if (message.type === 'llm_tool_call') { + toolCalls.push(message.toolName); + console.log('[Tool Call]', message.toolName); + } + } + + console.log('[Test] Used tools:', toolCalls.join(', ')); + console.log('[Test] Multiple tools complete\n'); + + // Test extension tools + console.log('[Test] Running with extension tools...'); + const query3 = mockSdk.query({ + prompt: 'Search the web', + options: { model: 'claude-sonnet-4-20250514', scenario: 'extensionTools' }, + }); + + for await (const message of query3) { + if (message.type === 'llm_tool_call') { + console.log('[Tool Call]', message.toolName, '- Type: extension'); + } + } + + console.log('[Test] Extension tools complete\n'); + + // Allow spans to be sent + await Sentry.flush(2000); + console.log('[Test] All tool scenarios complete'); +} + +run().catch(error => { + console.error('[Fatal error]', error); + process.exit(1); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario.mjs new file mode 100644 index 000000000000..fc4a2a8f7005 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario.mjs @@ -0,0 +1,126 @@ +/* eslint-disable no-console */ +import { patchClaudeCodeQuery } from '@sentry/node'; +import * as Sentry from '@sentry/node'; +import { createMockSdk } from './mock-server.mjs'; + +// This scenario tests basic agent invocation with manual patching +// The integration uses OpenTelemetry auto-instrumentation, but for testing +// we need to manually patch the mock SDK + +async function run() { + const mockSdk = createMockSdk(); + + // Manually patch the query function + const originalQuery = mockSdk.query.bind(mockSdk); + const patchedQuery = patchClaudeCodeQuery(originalQuery, { + agentName: 'claude-code', + }); + + // Basic query + console.log('[Test] Running basic agent invocation...'); + const query1 = patchedQuery({ + prompt: 'What is the capital of France?', + inputMessages: [{ role: 'user', content: 'What is the capital of France?' }], + options: { model: 'claude-sonnet-4-20250514', scenario: 'basic' }, + }); + + for await (const message of query1) { + console.log('[Message]', message.type); + if (message.type === 'llm_text') { + console.log('[LLM Text]', message.text); + } + } + + console.log('[Test] Basic invocation complete\n'); + + // Query with tool usage + console.log('[Test] Running agent invocation with tools...'); + const query2 = patchedQuery({ + prompt: 'Read the test file', + inputMessages: [{ role: 'user', content: 'Read the test file' }], + options: { model: 'claude-sonnet-4-20250514', scenario: 'withTools' }, + }); + + for await (const message of query2) { + console.log('[Message]', message.type); + if (message.type === 'llm_text') { + console.log('[LLM Text]', message.text); + } else if (message.type === 'llm_tool_call') { + console.log('[Tool Call]', message.toolName, message.toolInput); + } else if (message.type === 'tool_result') { + console.log('[Tool Result]', message.toolName, message.status); + } + } + + console.log('[Test] Tool invocation complete\n'); + + // Query with extension tools (WebSearch, WebFetch) + console.log('[Test] Running agent invocation with extension tools...'); + const query3 = patchedQuery({ + prompt: 'Search for information about Sentry', + inputMessages: [{ role: 'user', content: 'Search for information about Sentry' }], + options: { model: 'claude-sonnet-4-20250514', scenario: 'extensionTools' }, + }); + + for await (const message of query3) { + console.log('[Message]', message.type); + if (message.type === 'llm_tool_call') { + console.log('[Tool Call]', message.toolName, 'type:', getToolType(message.toolName)); + } + } + + console.log('[Test] Extension tools invocation complete\n'); + + // Test error handling + console.log('[Test] Running agent invocation with LLM error...'); + try { + const query4 = patchedQuery({ + prompt: 'This will fail', + inputMessages: [{ role: 'user', content: 'This will fail' }], + options: { model: 'claude-sonnet-4-20250514', scenario: 'llmError' }, + }); + + for await (const message of query4) { + console.log('[Message]', message.type); + if (message.type === 'error') { + throw message.error; + } + } + } catch (error) { + console.log('[Error caught]', error.message); + } + + console.log('[Test] Error handling complete\n'); + + // Allow spans to be sent + await Sentry.flush(2000); + console.log('[Test] All scenarios complete'); +} + +function getToolType(toolName) { + const functionTools = new Set([ + 'Bash', + 'BashOutput', + 'KillShell', + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'Task', + 'ExitPlanMode', + 'TodoWrite', + 'NotebookEdit', + 'SlashCommand', + ]); + const extensionTools = new Set(['WebSearch', 'WebFetch']); + + if (functionTools.has(toolName)) return 'function'; + if (extensionTools.has(toolName)) return 'extension'; + return 'function'; +} + +run().catch(error => { + console.error('[Fatal error]', error); + process.exit(1); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/test-simple.ts b/dev-packages/node-integration-tests/suites/tracing/claude-code/test-simple.ts new file mode 100644 index 000000000000..a6757608bd7f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/test-simple.ts @@ -0,0 +1,22 @@ +import { afterAll, describe } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('Claude Code Agent SDK integration - Simple', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + // Very basic expectation - just check that a transaction is created + createEsmAndCjsTests(__dirname, 'scenario-simple.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates a transaction with claude-code spans', async () => { + await createRunner() + .expect({ + transaction: { + transaction: 'invoke_agent claude-code', + }, + }) + .start() + .completed(); + }, 20000); // Increase timeout + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/test.ts b/dev-packages/node-integration-tests/suites/tracing/claude-code/test.ts new file mode 100644 index 000000000000..cca65fa29ca4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/test.ts @@ -0,0 +1,197 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('Claude Code Agent SDK integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + // Expected span structure for basic invocation (sendDefaultPii: false) + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'invoke_agent claude-code', + spans: expect.arrayContaining([ + // LLM chat span (child of agent span) + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.ai.claude-code', + 'sentry.op': 'gen_ai.chat', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-sonnet-4-20250514', + 'gen_ai.operation.name': 'chat', + 'gen_ai.response.id': expect.stringMatching(/^resp_/), + 'gen_ai.response.model': 'claude-sonnet-4-20250514', + 'gen_ai.usage.input_tokens': expect.any(Number), + 'gen_ai.usage.output_tokens': expect.any(Number), + 'gen_ai.usage.total_tokens': expect.any(Number), + // NO response.text (sendDefaultPii: false) + }), + description: expect.stringMatching(/^chat claude-sonnet/), + op: 'gen_ai.chat', + origin: 'auto.ai.claude-code', + status: 'ok', + }), + ]), + }; + + // Expected span structure with PII enabled + const EXPECTED_TRANSACTION_WITH_PII = { + transaction: 'invoke_agent claude-code', + spans: expect.arrayContaining([ + // LLM span with response text + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.response.text': expect.stringContaining('Hello!'), + 'gen_ai.response.id': expect.stringMatching(/^resp_/), + 'gen_ai.usage.input_tokens': expect.any(Number), + }), + op: 'gen_ai.chat', + status: 'ok', + }), + ]), + }; + + // Expected spans with tools + const EXPECTED_TRANSACTION_WITH_TOOLS = { + transaction: 'invoke_agent claude-code', + spans: expect.arrayContaining([ + // Tool execution span - Read (function type) + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.ai.claude-code', + 'gen_ai.tool.name': 'Read', + 'gen_ai.tool.type': 'function', + }), + description: 'execute_tool Read', + op: 'gen_ai.execute_tool', + origin: 'auto.ai.claude-code', + status: 'ok', + }), + + // LLM chat spans (should have multiple from the conversation) + expect.objectContaining({ + op: 'gen_ai.chat', + status: 'ok', + }), + ]), + }; + + // Expected spans with extension tools + const EXPECTED_TRANSACTION_WITH_EXTENSION_TOOLS = { + transaction: 'invoke_agent claude-code', + spans: expect.arrayContaining([ + // WebSearch - extension type + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.tool.name': 'WebSearch', + 'gen_ai.tool.type': 'extension', + }), + description: 'execute_tool WebSearch', + op: 'gen_ai.execute_tool', + }), + + // WebFetch - extension type + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.tool.name': 'WebFetch', + 'gen_ai.tool.type': 'extension', + }), + description: 'execute_tool WebFetch', + op: 'gen_ai.execute_tool', + }), + ]), + }; + + // Expected error handling + const EXPECTED_ERROR_EVENT = { + exception: { + values: [ + expect.objectContaining({ + type: 'Error', + value: expect.stringMatching(/Rate limit exceeded|Agent initialization failed/), + mechanism: { + type: 'auto.ai.claude-code', + handled: false, + }, + }), + ], + }, + }; + + // Basic tests with default PII settings + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates claude-code related spans with sendDefaultPii: false', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }).start().completed(); + }); + }); + + // Tests with PII enabled + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records input messages and response text with sendDefaultPii: true', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_WITH_PII }).start().completed(); + }); + }); + + // Tests with custom options + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + test('respects custom recordInputs/recordOutputs options', async () => { + await createRunner() + .expect({ + transaction: { + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + // recordInputs: true + 'gen_ai.request.messages': expect.any(String), + }), + op: 'gen_ai.invoke_agent', + }), + expect.objectContaining({ + data: expect.not.objectContaining({ + // recordOutputs: false + 'gen_ai.response.text': expect.anything(), + }), + op: 'gen_ai.chat', + }), + ]), + }, + }) + .start() + .completed(); + }); + }); + + // Tool execution tests + createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates tool execution spans with correct types', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_WITH_TOOLS }).start().completed(); + }); + + test('classifies extension tools correctly', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_WITH_EXTENSION_TOOLS }).start().completed(); + }); + }); + + // Error handling tests + createEsmAndCjsTests(__dirname, 'scenario-errors.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures errors with correct mechanism type', async () => { + await createRunner().expect({ event: EXPECTED_ERROR_EVENT }).start().completed(); + }); + + test('sets span status to error on failure', async () => { + await createRunner() + .expect({ + transaction: { + spans: expect.arrayContaining([ + expect.objectContaining({ + op: 'gen_ai.invoke_agent', + status: 'internal_error', + }), + ]), + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 793bd65429fc..b0413cebad57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -157,12 +157,14 @@ export { instrumentStateGraphCompile, instrumentLangGraph } from './tracing/lang export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants'; export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types'; -export { setTokenUsageAttributes } from './tracing/ai/utils'; +export { setTokenUsageAttributes, getTruncatedJsonString } from './tracing/ai/utils'; export { GEN_AI_AGENT_NAME_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_ID_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index eeea7e54c1c7..46d2917cf851 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -183,11 +183,6 @@ export const GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE = 'gen_ai.invoke_agent'; // AI AGENT ATTRIBUTES // ============================================================================= -/** - * The name of the AI agent - */ -export const GEN_AI_AGENT_NAME_ATTRIBUTE = 'gen_ai.agent.name'; - /** * The name of the tool being executed */ diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 8b6dc0f66f9a..c0bfae9083eb 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -20,12 +20,7 @@ import { stripUrlQueryAndFragment, } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { - getDefaultIntegrations, - httpIntegration, - init as nodeInit, - claudeCodeAgentSdkIntegration, -} from '@sentry/node'; +import { claudeCodeAgentSdkIntegration, getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; @@ -46,12 +41,6 @@ export * from '@sentry/node'; // We re-export this explicitly to ensure rollup doesn't tree-shake it export { claudeCodeAgentSdkIntegration }; -// Force rollup to keep the import by "using" it -const _forceInclude = { claudeCodeAgentSdkIntegration }; -if (false as boolean) { - console.log(_forceInclude); -} - export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; // Override core span methods with Next.js-specific implementations that support Cache Components diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 64f9ec7e0ef5..701e2edf48c3 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -26,7 +26,7 @@ export { amqplibIntegration } from './integrations/tracing/amqplib'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { openAIIntegration } from './integrations/tracing/openai'; export { anthropicAIIntegration } from './integrations/tracing/anthropic-ai'; -export { claudeCodeAgentSdkIntegration } from './integrations/tracing/claude-code'; +export { claudeCodeAgentSdkIntegration, patchClaudeCodeQuery } from './integrations/tracing/claude-code'; export type { ClaudeCodeOptions } from './integrations/tracing/claude-code'; export { googleGenAIIntegration } from './integrations/tracing/google-genai'; export { langChainIntegration } from './integrations/tracing/langchain'; diff --git a/packages/node/src/integrations/tracing/claude-code/index.ts b/packages/node/src/integrations/tracing/claude-code/index.ts index 0dca91f592ed..5e64d2e437a3 100644 --- a/packages/node/src/integrations/tracing/claude-code/index.ts +++ b/packages/node/src/integrations/tracing/claude-code/index.ts @@ -119,3 +119,6 @@ const _claudeCodeAgentSdkIntegration = ((options: ClaudeCodeOptions = {}) => { * @see https://docs.sentry.io/platforms/javascript/guides/node/ai-monitoring/ */ export const claudeCodeAgentSdkIntegration = defineIntegration(_claudeCodeAgentSdkIntegration); + +// Export for testing purposes only +export { patchClaudeCodeQuery } from './instrumentation'; diff --git a/packages/node/src/integrations/tracing/claude-code/instrumentation.ts b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts index 2c1557aea689..d738357dc006 100644 --- a/packages/node/src/integrations/tracing/claude-code/instrumentation.ts +++ b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts @@ -4,8 +4,10 @@ import { captureException, GEN_AI_AGENT_NAME_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_ID_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, @@ -16,6 +18,7 @@ import { GEN_AI_TOOL_OUTPUT_ATTRIBUTE, GEN_AI_TOOL_TYPE_ATTRIBUTE, getClient, + getTruncatedJsonString, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setTokenUsageAttributes, @@ -33,29 +36,42 @@ const SENTRY_ORIGIN = 'auto.ai.claude-code'; * Maps Claude Code tool names to OpenTelemetry tool types. * * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/ + * @see https://platform.claude.com/docs/en/agent-sdk/typescript * @param toolName - The name of the tool (e.g., 'Bash', 'Read', 'WebSearch') * @returns The OpenTelemetry tool type: 'function', 'extension', or 'datastore' */ function getToolType(toolName: string): 'function' | 'extension' | 'datastore' { // Client-side execution tools - functions that run on the client const functionTools = new Set([ + // Shell/process tools 'Bash', 'BashOutput', - 'KillShell', // Shell/process tools + 'KillBash', + + // File operations 'Read', 'Write', - 'Edit', // File operations + 'Edit', + 'NotebookEdit', + + // File search 'Glob', - 'Grep', // File search + 'Grep', + + // Agent control 'Task', 'ExitPlanMode', - 'TodoWrite', // Agent control - 'NotebookEdit', - 'SlashCommand', // Specialized operations + 'EnterPlanMode', + 'TodoWrite', + + // User interaction + 'AskUserQuestion', + 'SlashCommand', + 'Skill', ]); // Agent-side API calls - external service integrations - const extensionTools = new Set(['WebSearch', 'WebFetch']); + const extensionTools = new Set(['WebSearch', 'WebFetch', 'ListMcpResources', 'ReadMcpResource']); // Data access tools - database/structured data operations // (Currently none in Claude Code, but future-proofing) @@ -139,7 +155,7 @@ function _createInstrumentedGenerator( name: `invoke_agent ${agentName}`, op: 'gen_ai.invoke_agent', attributes: { - [GEN_AI_SYSTEM_ATTRIBUTE]: agentName, + [GEN_AI_SYSTEM_ATTRIBUTE]: 'anthropic', [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: model, [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'invoke_agent', [GEN_AI_AGENT_NAME_ATTRIBUTE]: agentName, @@ -149,272 +165,296 @@ function _createInstrumentedGenerator( }, // eslint-disable-next-line complexity async function* (span: Span) { - // State accumulation - let sessionId: string | null = null; - let currentLLMSpan: Span | null = null; - let currentTurnContent = ''; - let currentTurnTools: unknown[] = []; - let currentTurnId: string | null = null; - let currentTurnModel: string | null = null; - let inputMessagesCaptured = false; - let finalResult: string | null = null; - let previousLLMSpan: Span | null = null; - let previousTurnTools: unknown[] = []; - - try { - for await (const message of originalQuery) { - const msg = message as Record; - - // Extract session ID from system message - if (msg.type === 'system' && msg.session_id) { + // State accumulation + let sessionId: string | null = null; + let currentLLMSpan: Span | null = null; + let currentTurnContent = ''; + let currentTurnTools: unknown[] = []; + let currentTurnId: string | null = null; + let currentTurnModel: string | null = null; + let currentTurnStopReason: string | null = null; + let inputMessagesCaptured = false; + let finalResult: string | null = null; + let previousLLMSpan: Span | null = null; + let previousTurnTools: unknown[] = []; + + try { + for await (const message of originalQuery) { + const msg = message as Record; + + // Extract session ID and available tools from system message + if (msg.type === 'system') { + if (msg.session_id) { sessionId = msg.session_id as string; + } - if (!inputMessagesCaptured && instrumentationOptions.recordInputs && msg.conversation_history) { - span.setAttributes({ - [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(msg.conversation_history), - }); - inputMessagesCaptured = true; - } + // Capture available tools from system init message + if (msg.subtype === 'init' && Array.isArray(msg.tools)) { + span.setAttributes({ + [GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE]: JSON.stringify(msg.tools), + }); } - // Handle assistant messages - if (msg.type === 'assistant') { - // Close previous LLM span if still open - if (previousLLMSpan) { - previousLLMSpan.setStatus({ code: 1 }); - previousLLMSpan.end(); - previousLLMSpan = null; - previousTurnTools = []; - } + if (!inputMessagesCaptured && instrumentationOptions.recordInputs && msg.conversation_history) { + span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(msg.conversation_history), + }); + inputMessagesCaptured = true; + } + } - // Create new LLM span - if (!currentLLMSpan) { - currentLLMSpan = withActiveSpan(span, () => { - return startSpanManual( - { - name: `chat ${model}`, - op: 'gen_ai.chat', - attributes: { - [GEN_AI_SYSTEM_ATTRIBUTE]: agentName, - [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: model, - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', - }, - }, - (childSpan: Span) => { - if (instrumentationOptions.recordInputs && instrumentationOptions.inputMessages) { - childSpan.setAttributes({ - [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(instrumentationOptions.inputMessages), - }); - } - return childSpan; + // Handle assistant messages + if (msg.type === 'assistant') { + // Close previous LLM span if still open + if (previousLLMSpan) { + previousLLMSpan.setStatus({ code: 1 }); + previousLLMSpan.end(); + previousLLMSpan = null; + previousTurnTools = []; + } + + // Create new LLM span + if (!currentLLMSpan) { + currentLLMSpan = withActiveSpan(span, () => { + return startSpanManual( + { + name: `chat ${model}`, + op: 'gen_ai.chat', + attributes: { + [GEN_AI_SYSTEM_ATTRIBUTE]: 'anthropic', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: model, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', }, - ); - }); + }, + (childSpan: Span) => { + if (instrumentationOptions.recordInputs && instrumentationOptions.inputMessages) { + childSpan.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString( + instrumentationOptions.inputMessages, + ), + }); + } + return childSpan; + }, + ); + }); + + currentTurnContent = ''; + currentTurnTools = []; + } - currentTurnContent = ''; - currentTurnTools = []; + // Accumulate content + const content = (msg.message as Record)?.content as unknown[]; + if (Array.isArray(content)) { + const textContent = content + .filter(c => (c as Record).type === 'text') + .map(c => (c as Record).text as string) + .join(''); + if (textContent) { + currentTurnContent += textContent; } - // Accumulate content - const content = (msg.message as Record)?.content as unknown[]; - if (Array.isArray(content)) { - const textContent = content - .filter(c => (c as Record).type === 'text') - .map(c => (c as Record).text as string) - .join(''); - if (textContent) { - currentTurnContent += textContent; - } - - const tools = content.filter(c => (c as Record).type === 'tool_use'); - if (tools.length > 0) { - currentTurnTools.push(...tools); - } + const tools = content.filter(c => (c as Record).type === 'tool_use'); + if (tools.length > 0) { + currentTurnTools.push(...tools); } + } - if ((msg.message as Record)?.id) { - currentTurnId = (msg.message as Record).id as string; - } - if ((msg.message as Record)?.model) { - currentTurnModel = (msg.message as Record).model as string; - } + if ((msg.message as Record)?.id) { + currentTurnId = (msg.message as Record).id as string; + } + if ((msg.message as Record)?.model) { + currentTurnModel = (msg.message as Record).model as string; + } + if ((msg.message as Record)?.stop_reason) { + currentTurnStopReason = (msg.message as Record).stop_reason as string; } + } - // Handle result messages - if (msg.type === 'result') { - if (msg.result) { - finalResult = msg.result as string; + // Handle result messages + if (msg.type === 'result') { + if (msg.result) { + finalResult = msg.result as string; + } + + // Close previous LLM span + if (previousLLMSpan) { + previousLLMSpan.setStatus({ code: 1 }); + previousLLMSpan.end(); + previousLLMSpan = null; + previousTurnTools = []; + } + + // Finalize current LLM span + if (currentLLMSpan) { + if (instrumentationOptions.recordOutputs && currentTurnContent) { + currentLLMSpan.setAttributes({ + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: currentTurnContent, + }); } - // Close previous LLM span - if (previousLLMSpan) { - previousLLMSpan.setStatus({ code: 1 }); - previousLLMSpan.end(); - previousLLMSpan = null; - previousTurnTools = []; + if (instrumentationOptions.recordOutputs && currentTurnTools.length > 0) { + currentLLMSpan.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(currentTurnTools), + }); } - // Finalize current LLM span - if (currentLLMSpan) { - if (instrumentationOptions.recordOutputs && currentTurnContent) { - currentLLMSpan.setAttributes({ - [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: currentTurnContent, - }); - } - - if (instrumentationOptions.recordOutputs && currentTurnTools.length > 0) { - currentLLMSpan.setAttributes({ - [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(currentTurnTools), - }); - } - - if (currentTurnId) { - currentLLMSpan.setAttributes({ - [GEN_AI_RESPONSE_ID_ATTRIBUTE]: currentTurnId, - }); - } - if (currentTurnModel) { - currentLLMSpan.setAttributes({ - [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: currentTurnModel, - }); - } - - if (msg.usage) { - const usage = msg.usage as Record; - setTokenUsageAttributes( - currentLLMSpan, - usage.input_tokens, - usage.output_tokens, - usage.cache_creation_input_tokens, - usage.cache_read_input_tokens, - ); - } + if (currentTurnId) { + currentLLMSpan.setAttributes({ + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: currentTurnId, + }); + } + if (currentTurnModel) { + currentLLMSpan.setAttributes({ + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: currentTurnModel, + }); + } + if (currentTurnStopReason) { + currentLLMSpan.setAttributes({ + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify([currentTurnStopReason]), + }); + } + + if (msg.usage) { + const usage = msg.usage as Record; + setTokenUsageAttributes( + currentLLMSpan, + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + ); + } - currentLLMSpan.setStatus({ code: 1 }); - currentLLMSpan.end(); + currentLLMSpan.setStatus({ code: 1 }); + currentLLMSpan.end(); - previousLLMSpan = currentLLMSpan; - previousTurnTools = currentTurnTools; + previousLLMSpan = currentLLMSpan; + previousTurnTools = currentTurnTools; - currentLLMSpan = null; - currentTurnContent = ''; - currentTurnTools = []; - currentTurnId = null; - currentTurnModel = null; - } + currentLLMSpan = null; + currentTurnContent = ''; + currentTurnTools = []; + currentTurnId = null; + currentTurnModel = null; + currentTurnStopReason = null; } + } - // Handle tool results - if (msg.type === 'user' && (msg.message as Record)?.content) { - const content = (msg.message as Record).content as unknown[]; - const toolResults = Array.isArray(content) - ? content.filter(c => (c as Record).type === 'tool_result') - : []; - - for (const toolResult of toolResults) { - const tr = toolResult as Record; - let matchingTool = currentTurnTools.find(t => (t as Record).id === tr.tool_use_id) as + // Handle tool results + if (msg.type === 'user' && (msg.message as Record)?.content) { + const content = (msg.message as Record).content as unknown[]; + const toolResults = Array.isArray(content) + ? content.filter(c => (c as Record).type === 'tool_result') + : []; + + for (const toolResult of toolResults) { + const tr = toolResult as Record; + let matchingTool = currentTurnTools.find(t => (t as Record).id === tr.tool_use_id) as + | Record + | undefined; + let parentLLMSpan = currentLLMSpan; + + if (!matchingTool && previousTurnTools.length > 0) { + matchingTool = previousTurnTools.find(t => (t as Record).id === tr.tool_use_id) as | Record | undefined; - let parentLLMSpan = currentLLMSpan; - - if (!matchingTool && previousTurnTools.length > 0) { - matchingTool = previousTurnTools.find(t => (t as Record).id === tr.tool_use_id) as - | Record - | undefined; - parentLLMSpan = previousLLMSpan; - } - - if (matchingTool && parentLLMSpan) { - withActiveSpan(parentLLMSpan, () => { - const toolName = matchingTool.name as string; - const toolType = getToolType(toolName); - - startSpan( - { - name: `execute_tool ${toolName}`, - op: 'gen_ai.execute_tool', - attributes: { - [GEN_AI_SYSTEM_ATTRIBUTE]: agentName, - [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: model, - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'execute_tool', - [GEN_AI_AGENT_NAME_ATTRIBUTE]: agentName, - [GEN_AI_TOOL_NAME_ATTRIBUTE]: toolName, - [GEN_AI_TOOL_TYPE_ATTRIBUTE]: toolType, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.execute_tool', - }, - }, - (toolSpan: Span) => { - if (instrumentationOptions.recordInputs && matchingTool.input) { - toolSpan.setAttributes({ - [GEN_AI_TOOL_INPUT_ATTRIBUTE]: JSON.stringify(matchingTool.input), - }); - } - - if (instrumentationOptions.recordOutputs && tr.content) { - toolSpan.setAttributes({ - [GEN_AI_TOOL_OUTPUT_ATTRIBUTE]: - typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content), - }); - } - - // Set span status explicitly - if (tr.is_error) { - toolSpan.setStatus({ code: 2, message: 'Tool execution error' }); - } else { - toolSpan.setStatus({ code: 1 }); // Explicit success status - } - }, - ); - }); - } + parentLLMSpan = previousLLMSpan; } - } - yield message; - } + if (matchingTool && parentLLMSpan) { + withActiveSpan(parentLLMSpan, () => { + const toolName = matchingTool.name as string; + const toolType = getToolType(toolName); - if (instrumentationOptions.recordOutputs && finalResult) { - span.setAttributes({ - [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: finalResult, - }); - } + startSpan( + { + name: `execute_tool ${toolName}`, + op: 'gen_ai.execute_tool', + attributes: { + [GEN_AI_SYSTEM_ATTRIBUTE]: 'anthropic', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: model, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'execute_tool', + [GEN_AI_AGENT_NAME_ATTRIBUTE]: agentName, + [GEN_AI_TOOL_NAME_ATTRIBUTE]: toolName, + [GEN_AI_TOOL_TYPE_ATTRIBUTE]: toolType, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.execute_tool', + }, + }, + (toolSpan: Span) => { + if (instrumentationOptions.recordInputs && matchingTool.input) { + toolSpan.setAttributes({ + [GEN_AI_TOOL_INPUT_ATTRIBUTE]: getTruncatedJsonString(matchingTool.input), + }); + } + + if (instrumentationOptions.recordOutputs && tr.content) { + toolSpan.setAttributes({ + [GEN_AI_TOOL_OUTPUT_ATTRIBUTE]: + typeof tr.content === 'string' ? tr.content : getTruncatedJsonString(tr.content), + }); + } - if (sessionId) { - span.setAttributes({ - [GEN_AI_RESPONSE_ID_ATTRIBUTE]: sessionId, - }); + // Set span status explicitly + if (tr.is_error) { + toolSpan.setStatus({ code: 2, message: 'Tool execution error' }); + } else { + toolSpan.setStatus({ code: 1 }); // Explicit success status + } + }, + ); + }); + } + } } - span.setStatus({ code: 1 }); - } catch (error) { - // Capture exception to Sentry with proper metadata - captureException(error, { - mechanism: { - type: SENTRY_ORIGIN, - handled: false, - }, + yield message; + } + + if (instrumentationOptions.recordOutputs && finalResult) { + span.setAttributes({ + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: finalResult, }); + } - span.setStatus({ code: 2, message: (error as Error).message }); - throw error; - } finally { - // Ensure all child spans are closed even if generator exits early - if (currentLLMSpan?.isRecording()) { - currentLLMSpan.setStatus({ code: 1 }); - currentLLMSpan.end(); - } + if (sessionId) { + span.setAttributes({ + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: sessionId, + }); + } - if (previousLLMSpan?.isRecording()) { - previousLLMSpan.setStatus({ code: 1 }); - previousLLMSpan.end(); - } + span.setStatus({ code: 1 }); + } catch (error) { + // Capture exception to Sentry with proper metadata + captureException(error, { + mechanism: { + type: SENTRY_ORIGIN, + handled: false, + data: { + function: 'query', + }, + }, + }); + + span.setStatus({ code: 2, message: (error as Error).message }); + throw error; + } finally { + // Ensure all child spans are closed even if generator exits early + if (currentLLMSpan?.isRecording()) { + currentLLMSpan.setStatus({ code: 1 }); + currentLLMSpan.end(); + } - span.end(); + if (previousLLMSpan?.isRecording()) { + previousLLMSpan.setStatus({ code: 1 }); + previousLLMSpan.end(); } - }, - ); + + span.end(); + } + }, + ); } diff --git a/packages/node/src/integrations/tracing/claude-code/otel-instrumentation.ts b/packages/node/src/integrations/tracing/claude-code/otel-instrumentation.ts index 9abacadcd59e..6c423e896a96 100644 --- a/packages/node/src/integrations/tracing/claude-code/otel-instrumentation.ts +++ b/packages/node/src/integrations/tracing/claude-code/otel-instrumentation.ts @@ -53,10 +53,7 @@ export class SentryClaudeCodeAgentSdkInstrumentation extends InstrumentationBase } // Create wrapped query function - const wrappedQuery = function ( - this: unknown, - ...args: unknown[] - ): AsyncGenerator { + const wrappedQuery = function (this: unknown, ...args: unknown[]): AsyncGenerator { const client = getClient(); const defaultPii = Boolean(client?.getOptions().sendDefaultPii); @@ -93,11 +90,7 @@ export class SentryClaudeCodeAgentSdkInstrumentation extends InstrumentationBase } // Also patch default export if it has a query property - if ( - moduleExports.default && - typeof moduleExports.default === 'object' && - 'query' in moduleExports.default - ) { + if (moduleExports.default && typeof moduleExports.default === 'object' && 'query' in moduleExports.default) { try { (moduleExports.default as Record).query = wrappedQuery; } catch {