diff --git a/.size-limit.js b/.size-limit.js index e8124a622962..6d33784e9014 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -287,7 +287,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '166 KB', + limit: '168 KB', }, { name: '@sentry/node - without tracing', 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.cjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/mock-server.cjs new file mode 100644 index 000000000000..4331f3c499e2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/mock-server.cjs @@ -0,0 +1,523 @@ +/* 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 + */ + +let sessionCounter = 0; + +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 (primary input to the agent) + * @param {Object} params.options - Query options + * @param {string} params.options.model - Model to use + */ + 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_${Date.now()}_${++sessionCounter}`; + const scenarioName = params.options?.scenario || 'basic'; + + // Get scenario or use default + const scenario = this.scenarios[scenarioName] || this._getBasicScenario(); + + // 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() { + 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 + // Note: conversation_history is empty because the real SDK uses `prompt` as input, + // not inputMessages. The prompt is captured directly on the invoke_agent span. + { + type: 'system', + session_id: 'will-be-replaced', + model: 'will-be-replaced', + conversation_history: [], + }, + // 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 + */ +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 + */ +function createMockSdk() { + return new MockClaudeAgentSdk(SCENARIOS); +} + +module.exports = { + MockClaudeAgentSdk, + SCENARIOS, + createMockSdk, +}; 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..3389a37926d2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/mock-server.mjs @@ -0,0 +1,517 @@ +/* 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 + */ + +let sessionCounter = 0; + +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 (primary input to the agent) + * @param {Object} params.options - Query options + * @param {string} params.options.model - Model to use + */ + 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_${Date.now()}_${++sessionCounter}`; + const scenarioName = params.options?.scenario || 'basic'; + + // Get scenario or use default + const scenario = this.scenarios[scenarioName] || this._getBasicScenario(); + + // 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() { + 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 + // Note: conversation_history is empty because the real SDK uses `prompt` as input, + // not inputMessages. The prompt is captured directly on the invoke_agent span. + { + type: 'system', + session_id: 'will-be-replaced', + model: 'will-be-replaced', + conversation_history: [], + }, + // 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..4c379cff7e84 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-errors.mjs @@ -0,0 +1,41 @@ +/* eslint-disable no-console */ +import { patchClaudeCodeQuery } from '@sentry/node'; +import * as Sentry from '@sentry/node'; +import { createMockSdk } from './mock-server.mjs'; + +// This scenario tests error handling with a single error case +// to verify the span status is set correctly on failure. + +async function run() { + const mockSdk = createMockSdk(); + + // Manually patch the query function + const originalQuery = mockSdk.query.bind(mockSdk); + const patchedQuery = patchClaudeCodeQuery(originalQuery, { + agentName: 'claude-code', + }); + + // Test agent initialization error + console.log('[Test] Running agent initialization error...'); + try { + const query = patchedQuery({ + prompt: 'This will fail at agent init', + options: { model: 'claude-sonnet-4-20250514', scenario: 'agentError' }, + }); + + for await (const message of query) { + console.log('[Message]', message.type); + } + } catch (error) { + console.log('[Error caught]', error.message); + } + + // Allow spans to be sent + await Sentry.flush(2000); + console.log('[Test] Error scenario 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-extension-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-extension-tools.mjs new file mode 100644 index 000000000000..823a29916bb8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-extension-tools.mjs @@ -0,0 +1,39 @@ +/* eslint-disable no-console */ +import { patchClaudeCodeQuery } from '@sentry/node'; +import * as Sentry from '@sentry/node'; +import { createMockSdk } from './mock-server.mjs'; + +// This scenario specifically tests extension tool classification (WebSearch, WebFetch) + +async function run() { + const mockSdk = createMockSdk(); + + // Manually patch the query function + const originalQuery = mockSdk.query.bind(mockSdk); + const patchedQuery = patchClaudeCodeQuery(originalQuery, { + agentName: 'claude-code', + }); + + // Test extension tools + console.log('[Test] Running with extension tools...'); + const query = patchedQuery({ + prompt: 'Search the web', + options: { model: 'claude-sonnet-4-20250514', scenario: 'extensionTools' }, + }); + + for await (const message of query) { + if (message.type === 'llm_tool_call') { + console.log('[Tool Call]', message.toolName, '- Type: extension'); + } + } + + console.log('[Test] Extension tools complete'); + + // Allow spans to be sent + await Sentry.flush(2000); +} + +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..33cb042e279d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-simple.mjs @@ -0,0 +1,87 @@ +/* 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 + // Note: conversation_history is empty because the real SDK uses `prompt` as input. + // The prompt is captured directly on the invoke_agent span. + yield { + type: 'system', + session_id: 'sess_test123', + model: 'claude-sonnet-4-20250514', + conversation_history: [], + }; + + // 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', + 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..cc682228a38f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-tools.mjs @@ -0,0 +1,41 @@ +/* eslint-disable no-console */ +import { patchClaudeCodeQuery } from '@sentry/node'; +import * as Sentry from '@sentry/node'; +import { createMockSdk } from './mock-server.mjs'; + +// This scenario tests function tool execution (Read, Bash, Glob, etc.) + +async function run() { + const mockSdk = createMockSdk(); + + // Manually patch the query function + const originalQuery = mockSdk.query.bind(mockSdk); + const patchedQuery = patchClaudeCodeQuery(originalQuery, { + agentName: 'claude-code', + }); + + // Test function tools + console.log('[Test] Running with function tools (Read)...'); + const query = patchedQuery({ + prompt: 'Read the file', + options: { model: 'claude-sonnet-4-20250514', scenario: 'withTools' }, + }); + + for await (const message of query) { + 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'); + + // Allow spans to be sent + await Sentry.flush(2000); +} + +run().catch(error => { + console.error('[Fatal error]', error); + process.exit(1); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-with-options.mjs new file mode 100644 index 000000000000..6bddf4a0e834 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-with-options.mjs @@ -0,0 +1,40 @@ +import { patchClaudeCodeQuery } from '@sentry/node'; +import * as Sentry from '@sentry/node'; +import { createMockSdk } from './mock-server.mjs'; + +// This scenario tests custom recordInputs/recordOutputs options +// It uses manual patching with explicit options that match instrument-with-options.mjs: +// - recordInputs: true (input messages SHOULD be recorded) +// - recordOutputs: false (output text should NOT be recorded) + +async function run() { + const mockSdk = createMockSdk(); + + // Manually patch the query function with options that match the integration config + const originalQuery = mockSdk.query.bind(mockSdk); + const patchedQuery = patchClaudeCodeQuery(originalQuery, { + agentName: 'claude-code', + recordInputs: true, + recordOutputs: false, + }); + + // Basic query + const query1 = patchedQuery({ + prompt: 'What is the capital of France?', + options: { model: 'claude-sonnet-4-20250514', scenario: 'basic' }, + }); + + for await (const message of query1) { + // Consume all messages + if (message.type === 'error') { + throw message.error; + } + } + + // Allow spans to be sent + await Sentry.flush(2000); +} + +run().catch(() => { + 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..63eebd40a2a3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario.mjs @@ -0,0 +1,79 @@ +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 + const query1 = patchedQuery({ + prompt: 'What is the capital of France?', + options: { model: 'claude-sonnet-4-20250514', scenario: 'basic' }, + }); + + for await (const message of query1) { + // Consume all messages + if (message.type === 'error') { + throw message.error; + } + } + + // Query with tool usage + const query2 = patchedQuery({ + prompt: 'Read the test file', + options: { model: 'claude-sonnet-4-20250514', scenario: 'withTools' }, + }); + + for await (const message of query2) { + // Consume all messages + if (message.type === 'error') { + throw message.error; + } + } + + // Query with extension tools (WebSearch, WebFetch) + const query3 = patchedQuery({ + prompt: 'Search for information about Sentry', + options: { model: 'claude-sonnet-4-20250514', scenario: 'extensionTools' }, + }); + + for await (const message of query3) { + // Consume all messages + if (message.type === 'error') { + throw message.error; + } + } + + // Test error handling + try { + const query4 = patchedQuery({ + prompt: 'This will fail', + options: { model: 'claude-sonnet-4-20250514', scenario: 'llmError' }, + }); + + for await (const message of query4) { + if (message.type === 'error') { + throw message.error; + } + } + } catch { + // Expected error - swallow it + } + + // Allow spans to be sent + await Sentry.flush(2000); +} + +run().catch(() => { + 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..210ff5dd41c6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/test.ts @@ -0,0 +1,221 @@ +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', + }), + ]), + }; + + const copyPaths = ['mock-server.mjs', 'mock-server.cjs']; + + // 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(); + }); + }, + { copyPaths }, + ); + + // 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(); + }); + }, + { copyPaths }, + ); + + // Tests with custom options + createEsmAndCjsTests( + __dirname, + 'scenario-with-options.mjs', + 'instrument-with-options.mjs', + (createRunner, test) => { + test('respects custom recordInputs/recordOutputs options', async () => { + await createRunner() + .expect({ + transaction: { + transaction: 'invoke_agent claude-code', + // recordInputs: true - messages should be recorded on root span + contexts: { + trace: expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.messages': expect.any(String), + }), + }), + }, + // recordOutputs: false - response text should NOT be recorded on chat spans + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.not.objectContaining({ + 'gen_ai.response.text': expect.anything(), + }), + op: 'gen_ai.chat', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + { copyPaths }, + ); + + // Tool execution tests - function tools (Read, Bash, etc.) + 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(); + }); + }, + { copyPaths }, + ); + + // Tool execution tests - extension tools (WebSearch, WebFetch) + createEsmAndCjsTests( + __dirname, + 'scenario-extension-tools.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('classifies extension tools correctly', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_WITH_EXTENSION_TOOLS }).start().completed(); + }); + }, + { copyPaths }, + ); + + // Error handling tests + createEsmAndCjsTests( + __dirname, + 'scenario-errors.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('sets span status to error on failure', async () => { + await createRunner() + .expect({ + transaction: { + transaction: 'invoke_agent claude-code', + contexts: { + trace: expect.objectContaining({ + op: 'gen_ai.invoke_agent', + status: 'internal_error', + }), + }, + }, + }) + .start() + .completed(); + }); + }, + { copyPaths }, + ); +}); diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index ee2fae0bc06b..648baf84ef64 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -805,27 +805,37 @@ function expectMetric(item: SerializedMetricContainer, expected: ExpectedMetricC function convertEsmToCjs(content: string): string { let newContent = content; + // Helper to convert .mjs paths to .cjs for local relative imports + const convertModulePath = (modulePath: string): string => { + if (modulePath.startsWith('.') && modulePath.endsWith('.mjs')) { + return modulePath.replace(/\.mjs$/, '.cjs'); + } + return modulePath; + }; + // Handle default imports: import x from 'y' -> const x = require('y') newContent = newContent.replace( // eslint-disable-next-line regexp/optimal-quantifier-concatenation, regexp/no-super-linear-backtracking /import\s+([\w*{}\s,]+)\s+from\s+['"]([^'"]+)['"]/g, (_, imports: string, module: string) => { + const cjsModule = convertModulePath(module); if (imports.includes('* as')) { // Handle namespace imports: import * as x from 'y' -> const x = require('y') - return `const ${imports.replace('* as', '').trim()} = require('${module}')`; + return `const ${imports.replace('* as', '').trim()} = require('${cjsModule}')`; } else if (imports.includes('{')) { // Handle named imports: import {x, y} from 'z' -> const {x, y} = require('z') - return `const ${imports} = require('${module}')`; + return `const ${imports} = require('${cjsModule}')`; } else { // Handle default imports: import x from 'y' -> const x = require('y') - return `const ${imports} = require('${module}')`; + return `const ${imports} = require('${cjsModule}')`; } }, ); // Handle side-effect imports: import 'x' -> require('x') newContent = newContent.replace(/import\s+['"]([^'"]+)['"]/g, (_, module) => { - return `require('${module}')`; + const cjsModule = convertModulePath(module); + return `require('${cjsModule}')`; }); return newContent; diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 28623724db19..697825ede089 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -166,6 +166,8 @@ export { unleashIntegration, growthbookIntegration, metrics, + claudeCodeAgentSdkIntegration, + patchClaudeCodeQuery, } from '@sentry/node'; export { init } from './server/sdk'; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index fd0a0cb83095..f42d7f67702e 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -152,6 +152,8 @@ export { unleashIntegration, growthbookIntegration, metrics, + claudeCodeAgentSdkIntegration, + patchClaudeCodeQuery, } from '@sentry/node'; export { diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 9de1e55dacb6..7e93b1a3317b 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -171,6 +171,8 @@ export { statsigIntegration, unleashIntegration, metrics, + claudeCodeAgentSdkIntegration, + patchClaudeCodeQuery, } from '@sentry/node'; export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 19a83d230155..2a4cac1a77f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -157,6 +157,26 @@ 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, 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, + 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, + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_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 4fa7274d7281..52c17d878b96 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -232,6 +232,30 @@ export const GEN_AI_EMBED_MANY_DO_EMBED_OPERATION_ATTRIBUTE = 'gen_ai.embed_many */ export const GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE = 'gen_ai.execute_tool'; +// ============================================================================= +// AI AGENT ATTRIBUTES +// ============================================================================= + +/** + * 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 // ============================================================================= diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index 4a7a14eea554..8c3bbb2adb03 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -4,6 +4,8 @@ import type { Span } from '../../types-hoist/span'; import { GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from './gen-ai-attributes'; @@ -47,15 +49,15 @@ export function buildMethodPath(currentPath: string, prop: string): string { * @param span - The span to add attributes to * @param promptTokens - The number of prompt tokens * @param completionTokens - The number of completion tokens - * @param cachedInputTokens - The number of cached input tokens - * @param cachedOutputTokens - The number of cached output tokens + * @param cacheWriteTokens - The number of cache creation/write input tokens + * @param cacheReadTokens - The number of cache read input tokens */ export function setTokenUsageAttributes( span: Span, promptTokens?: number, completionTokens?: number, - cachedInputTokens?: number, - cachedOutputTokens?: number, + cacheWriteTokens?: number, + cacheReadTokens?: number, ): void { if (promptTokens !== undefined) { span.setAttributes({ @@ -67,18 +69,28 @@ export function setTokenUsageAttributes( [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: completionTokens, }); } + if (cacheWriteTokens !== undefined) { + span.setAttributes({ + [GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE]: cacheWriteTokens, + }); + } + if (cacheReadTokens !== undefined) { + span.setAttributes({ + [GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE]: cacheReadTokens, + }); + } if ( promptTokens !== undefined || completionTokens !== undefined || - cachedInputTokens !== undefined || - cachedOutputTokens !== undefined + cacheWriteTokens !== undefined || + cacheReadTokens !== undefined ) { /** * Total input tokens in a request is the summation of `input_tokens`, * `cache_creation_input_tokens`, and `cache_read_input_tokens`. */ const totalTokens = - (promptTokens ?? 0) + (completionTokens ?? 0) + (cachedInputTokens ?? 0) + (cachedOutputTokens ?? 0); + (promptTokens ?? 0) + (completionTokens ?? 0) + (cacheWriteTokens ?? 0) + (cacheReadTokens ?? 0); span.setAttributes({ [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: totalTokens, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 4fa5c727be59..670d1d5cd741 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -152,6 +152,8 @@ export { statsigIntegration, unleashIntegration, metrics, + claudeCodeAgentSdkIntegration, + patchClaudeCodeQuery, } from '@sentry/node'; export { diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 91d1dd65ca06..2d18a66850ca 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -20,7 +20,7 @@ import { stripUrlQueryAndFragment, } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrations, httpIntegration, init as nodeInit } 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'; @@ -38,6 +38,10 @@ import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; export * from '@sentry/node'; +// Explicit re-export for Claude Code integration +// We re-export this explicitly to ensure rollup doesn't tree-shake it +export { claudeCodeAgentSdkIntegration }; + 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 84fdf97539bc..94e025769f44 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 { 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'; 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..6f5bc8f9f859 --- /dev/null +++ b/packages/node/src/integrations/tracing/claude-code/helpers.ts @@ -0,0 +1,456 @@ +/* eslint-disable max-lines */ +import type { Span } from '@opentelemetry/api'; +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, + 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, + getClient, + getTruncatedJsonString, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + setTokenUsageAttributes, + startSpan, + startSpanManual, + withActiveSpan, +} from '@sentry/core'; +import type { ClaudeCodeOptions } from './types'; + +export type ClaudeCodeInstrumentationOptions = ClaudeCodeOptions; + +const SENTRY_ORIGIN = 'auto.ai.claude_code'; + +// Extension tools (external API calls) - everything else defaults to 'function' +const EXTENSION_TOOLS = new Set(['WebSearch', 'WebFetch', 'ListMcpResources', 'ReadMcpResource']); + +/** Maps tool names to OpenTelemetry tool types. */ +function getToolType(toolName: string): 'function' | 'extension' | 'datastore' { + if (EXTENSION_TOOLS.has(toolName)) return 'extension'; + return 'function'; +} + +/** Finalizes an LLM span with response attributes and ends it. */ +function finalizeLLMSpan( + s: Span, + c: string, + t: unknown[], + i: string | null, + m: string | null, + r: string | null, + o: boolean, +): void { + const a: Record = {}; + if (o && c) a[GEN_AI_RESPONSE_TEXT_ATTRIBUTE] = c; + if (o && t.length) a[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] = JSON.stringify(t); + if (i) a[GEN_AI_RESPONSE_ID_ATTRIBUTE] = i; + if (m) a[GEN_AI_RESPONSE_MODEL_ATTRIBUTE] = m; + if (r) a[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE] = JSON.stringify([r]); + s.setAttributes(a); + s.setStatus({ code: 1 }); + s.end(); +} + +/** + * 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; + const agentName = options.agentName ?? 'claude-code'; + + const [queryParams] = args as [Record]; + const { options: queryOptions, prompt } = queryParams || {}; + const model = (queryOptions as Record)?.model ?? 'unknown'; + + const originalQueryInstance = queryFunction.apply(this, args); + const instrumentedGenerator = _createInstrumentedGenerator(originalQueryInstance, model as string, { + recordInputs, + recordOutputs, + prompt, + agentName, + }); + if (typeof (originalQueryInstance as Record).interrupt === 'function') { + (instrumentedGenerator as unknown as Record).interrupt = ( + (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 (...args: unknown[]) => unknown + ).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; + prompt?: unknown; + agentName?: string; + }, +): AsyncGenerator { + const agentName = instrumentationOptions.agentName ?? 'claude-code'; + const attributes: Record = { + [GEN_AI_SYSTEM_ATTRIBUTE]: 'anthropic', + [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', + }; + + if (instrumentationOptions.recordInputs && instrumentationOptions.prompt) { + attributes[GEN_AI_REQUEST_MESSAGES_ATTRIBUTE] = getTruncatedJsonString(instrumentationOptions.prompt); + } + + return startSpanManual( + { + name: `invoke_agent ${agentName}`, + op: 'gen_ai.invoke_agent', + attributes, + }, + (span: Span) => _instrumentQueryGenerator(originalQuery, span, model, agentName, instrumentationOptions), + ); +} + +// eslint-disable-next-line complexity +async function* _instrumentQueryGenerator( + originalQuery: AsyncGenerator, + span: Span, + model: string, + agentName: string, + instrumentationOptions: { + recordInputs?: boolean; + recordOutputs?: boolean; + prompt?: unknown; + agentName?: string; + }, +): AsyncGenerator { + 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 finalResult: string | null = null; + let previousLLMSpan: Span | null = null; + let previousTurnTools: unknown[] = []; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheCreationTokens = 0; + let totalCacheReadTokens = 0; + let encounteredError = false; + + try { + for await (const message of originalQuery) { + const msg = message as Record; + + if (msg.type === 'system') { + if (msg.session_id) { + sessionId = msg.session_id as string; + } + if (msg.subtype === 'init' && Array.isArray(msg.tools)) { + span.setAttributes({ + [GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE]: JSON.stringify(msg.tools), + }); + } + } + + if (msg.type === 'assistant') { + if (previousLLMSpan?.isRecording()) { + previousLLMSpan.setStatus({ code: 1 }); + previousLLMSpan.end(); + } + previousLLMSpan = null; + previousTurnTools = []; + + if (currentLLMSpan) { + finalizeLLMSpan( + currentLLMSpan, + currentTurnContent, + currentTurnTools, + currentTurnId, + currentTurnModel, + currentTurnStopReason, + instrumentationOptions.recordOutputs ?? false, + ); + previousLLMSpan = currentLLMSpan; + previousTurnTools = currentTurnTools; + } + + 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.prompt) { + childSpan.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(instrumentationOptions.prompt), + }); + } + return childSpan; + }, + ); + }); + + currentTurnContent = ''; + currentTurnTools = []; + currentTurnId = null; + currentTurnModel = null; + currentTurnStopReason = null; + + 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; + } + if ((msg.message as Record)?.stop_reason) { + currentTurnStopReason = (msg.message as Record).stop_reason as string; + } + + const messageUsage = (msg.message as Record)?.usage as Record | undefined; + if (messageUsage && currentLLMSpan) { + setTokenUsageAttributes( + currentLLMSpan, + messageUsage.input_tokens, + messageUsage.output_tokens, + messageUsage.cache_creation_input_tokens, + messageUsage.cache_read_input_tokens, + ); + totalInputTokens += messageUsage.input_tokens ?? 0; + totalOutputTokens += messageUsage.output_tokens ?? 0; + totalCacheCreationTokens += messageUsage.cache_creation_input_tokens ?? 0; + totalCacheReadTokens += messageUsage.cache_read_input_tokens ?? 0; + } + } + + if (msg.type === 'result') { + if (msg.result) { + finalResult = msg.result as string; + } + + if (previousLLMSpan?.isRecording()) { + previousLLMSpan.setStatus({ code: 1 }); + previousLLMSpan.end(); + } + previousLLMSpan = null; + previousTurnTools = []; + + if (currentLLMSpan) { + finalizeLLMSpan( + currentLLMSpan, + currentTurnContent, + currentTurnTools, + currentTurnId, + currentTurnModel, + currentTurnStopReason, + instrumentationOptions.recordOutputs ?? false, + ); + previousLLMSpan = currentLLMSpan; + previousTurnTools = currentTurnTools; + currentLLMSpan = null; + currentTurnContent = ''; + currentTurnTools = []; + currentTurnId = null; + currentTurnModel = null; + currentTurnStopReason = null; + } + } + + 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, () => { + 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]: '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), + }); + } + + toolSpan.setStatus(tr.is_error ? { code: 2, message: 'Tool execution error' } : { code: 1 }); + }, + ); + }); + } + } + } + + if (msg.type === 'error') { + encounteredError = true; + const errorType = (msg.error as Record)?.type || 'sdk_error'; + const originalError = msg.error; + const errorToCapture = + originalError instanceof Error + ? originalError + : new Error( + String((msg.error as Record)?.message || msg.message || 'Claude Code SDK error'), + ); + + captureException(errorToCapture, { + mechanism: { + type: SENTRY_ORIGIN, + handled: false, + data: { + function: 'query', + errorType: String(errorType), + }, + }, + }); + + span.setStatus({ code: 2, message: errorToCapture.message }); + } + + yield message; + } + + if (instrumentationOptions.recordOutputs && finalResult) { + span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: finalResult }); + } + + if (sessionId) { + span.setAttributes({ [GEN_AI_RESPONSE_ID_ATTRIBUTE]: sessionId }); + } + + if (totalInputTokens > 0 || totalOutputTokens > 0) { + setTokenUsageAttributes( + span, + totalInputTokens, + totalOutputTokens, + totalCacheCreationTokens, + totalCacheReadTokens, + ); + } + + if (!encounteredError) { + span.setStatus({ code: 1 }); + } + } catch (error) { + // Only capture if we haven't already captured this error from an error message + if (!encounteredError) { + captureException(error, { + mechanism: { type: SENTRY_ORIGIN, handled: false, data: { function: 'query' } }, + }); + } + span.setStatus({ code: 2, message: (error as Error).message }); + encounteredError = true; + throw error; + } finally { + if (currentLLMSpan?.isRecording()) { + if (encounteredError) { + currentLLMSpan.setStatus({ code: 2, message: 'Parent operation failed' }); + } else { + currentLLMSpan.setStatus({ code: 1 }); + } + currentLLMSpan.end(); + } + + if (previousLLMSpan?.isRecording()) { + if (encounteredError) { + previousLLMSpan.setStatus({ code: 2, message: 'Parent operation failed' }); + } else { + previousLLMSpan.setStatus({ code: 1 }); + } + previousLLMSpan.end(); + } + span.end(); + } +} 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..56b190405da1 --- /dev/null +++ b/packages/node/src/integrations/tracing/claude-code/index.ts @@ -0,0 +1,41 @@ +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node-core'; +import { SentryClaudeCodeAgentSdkInstrumentation } from './instrumentation'; +import type { ClaudeCodeOptions } from './types'; + +export type { ClaudeCodeOptions } from './types'; +export { patchClaudeCodeQuery } from './helpers'; + +export const CLAUDE_CODE_AGENT_SDK_INTEGRATION_NAME = 'ClaudeCodeAgentSdk'; + +/** + * 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 _claudeCodeAgentSdkIntegration = ((options: ClaudeCodeOptions = {}) => { + return { + name: CLAUDE_CODE_AGENT_SDK_INTEGRATION_NAME, + setupOnce() { + instrumentClaudeCodeAgentSdk(options); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for the Claude Code Agent SDK. + * Instruments `query` from `@anthropic-ai/claude-agent-sdk` to capture spans. + * + * **Important**: Initialize Sentry BEFORE importing `@anthropic-ai/claude-agent-sdk`. + * + * Options: + * - `recordInputs`: Record prompt messages (default: `sendDefaultPii` setting) + * - `recordOutputs`: Record responses/tool outputs (default: `sendDefaultPii` setting) + * - `agentName`: Custom agent name (default: 'claude-code') + */ +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 new file mode 100644 index 000000000000..bc7aeccffdd8 --- /dev/null +++ b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts @@ -0,0 +1,77 @@ +import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; +import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import { getClient, SDK_VERSION } from '@sentry/core'; +import { patchClaudeCodeQuery } from './helpers'; +import type { ClaudeCodeOptions } from './types'; + +const SUPPORTED_VERSIONS = ['>=0.1.0 <1.0.0']; + +type ClaudeCodeInstrumentationConfig = InstrumentationConfig & ClaudeCodeOptions; + +interface ClaudeAgentSdkModuleExports { + [key: string]: unknown; + query: (...args: unknown[]) => AsyncGenerator; +} + +/** OpenTelemetry instrumentation for the Claude Code Agent SDK. */ +export class SentryClaudeCodeAgentSdkInstrumentation extends InstrumentationBase { + public constructor(config: ClaudeCodeInstrumentationConfig = {}) { + super('@sentry/instrumentation-claude-code-agent-sdk', SDK_VERSION, config); + } + + /** @inheritdoc */ + public init(): InstrumentationModuleDefinition { + return new InstrumentationNodeModuleDefinition( + '@anthropic-ai/claude-agent-sdk', + SUPPORTED_VERSIONS, + this._patch.bind(this), + ); + } + + 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; + } + + 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', + }; + return patchClaudeCodeQuery(originalQuery, options).apply(this, args); + }; + + Object.defineProperty(wrappedQuery, 'name', { value: originalQuery.name }); + Object.defineProperty(wrappedQuery, 'length', { value: originalQuery.length }); + + // ESM vs CJS handling + if (Object.prototype.toString.call(moduleExports) === '[object Module]') { + try { + moduleExports.query = wrappedQuery; + } catch { + Object.defineProperty(moduleExports, 'query', { + value: wrappedQuery, + writable: true, + configurable: true, + enumerable: true, + }); + } + if (moduleExports.default && typeof moduleExports.default === 'object' && 'query' in moduleExports.default) { + try { + (moduleExports.default as Record).query = wrappedQuery; + } catch { + /* ignore */ + } + } + return moduleExports; + } + 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; +} 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, ]; }