From 78624294c08decd2fe0deff8cbdacb2f480b53d9 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 14:09:05 -0700 Subject: [PATCH 1/8] 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 | 2 + 5 files changed, 616 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 4808f22b472b..4cf4765d44c9 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 { launchDarklyIntegration, 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 dd9d9ac8df2b..48b5f75970f6 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 { claudeCodeIntegration } from './claude-code'; import { connectIntegration, instrumentConnect } from './connect'; import { expressIntegration, instrumentExpress } from './express'; import { fastifyIntegration, instrumentFastify, instrumentFastifyV3 } from './fastify'; @@ -56,6 +57,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { firebaseIntegration(), anthropicAIIntegration(), googleGenAIIntegration(), + claudeCodeIntegration(), ]; } From 6373d01aea36d8c347c60d407b8441c237a28471 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 14:20:56 -0700 Subject: [PATCH 2/8] 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 e0e651bbcf75d65bc09a7755f151c4e03b53bfb9 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 14:33:12 -0700 Subject: [PATCH 3/8] 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 736b0ef9839bbf757770cd38f1f15cbf26e50093 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 14:46:50 -0700 Subject: [PATCH 4/8] 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 dbcf9816bf8b23510a79ec185a29044d99b7f0f3 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 16:47:31 -0700 Subject: [PATCH 5/8] 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 d0c2c3b9a7b6f07dc560c0989b077c59652cb37a Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 17:09:11 -0700 Subject: [PATCH 6/8] 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 fe5a75bd5c8b..85da1fdb9c57 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -25,6 +25,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 dcc42a8f37dd77af4e6af293e3a6985f9f228664 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 17:14:04 -0700 Subject: [PATCH 7/8] 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 5866f014ec69..cb107873f6a3 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -41,6 +41,9 @@ import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegrati 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'; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { From c897e7696dca31df498b7e90e935a43d003408fd Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 1 Oct 2025 17:33:54 -0700 Subject: [PATCH 8/8] 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 cb107873f6a3..83ffd0692cc6 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -26,7 +26,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 { getScopesFromContext } from '@sentry/opentelemetry'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; @@ -42,7 +49,12 @@ import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegrati 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';