diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-options.mjs new file mode 100644 index 000000000000..3c45fed6332e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-options.mjs @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Configuration with custom options that override sendDefaultPii +// sendDefaultPii: false, but recordInputs: true, recordOutputs: false +// This means input messages SHOULD be recorded, but NOT output text + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [ + Sentry.claudeCodeAgentSdkIntegration({ + recordInputs: true, + recordOutputs: false, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-pii.mjs new file mode 100644 index 000000000000..36c81e86afae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument-with-pii.mjs @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Configuration with PII enabled +// This means input messages and output text SHOULD be recorded + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [Sentry.claudeCodeAgentSdkIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument.mjs new file mode 100644 index 000000000000..7aea43214263 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/instrument.mjs @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Default configuration: sendDefaultPii: false +// This means NO input messages or output text should be recorded by default + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [Sentry.claudeCodeAgentSdkIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/mock-server.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/mock-server.mjs new file mode 100644 index 000000000000..ba5181f7cbc9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/mock-server.mjs @@ -0,0 +1,514 @@ +/* eslint-disable no-console, max-lines */ +/** + * Mock implementation of @anthropic-ai/claude-agent-sdk + * Simulates the query function behavior for testing + * + * Message format matches the real Claude Agent SDK: + * - type: 'system' - Session initialization + * - type: 'assistant' - LLM responses + * - type: 'user' - Tool results + * - type: 'result' - Final result + */ + +export class MockClaudeAgentSdk { + constructor(scenarios = {}) { + this.scenarios = scenarios; + } + + /** + * Mock query function that returns an AsyncGenerator + * @param {Object} params - Query parameters + * @param {string} params.prompt - The prompt text + * @param {Object} params.options - Query options + * @param {string} params.options.model - Model to use + * @param {Array} params.inputMessages - Previous conversation messages + */ + query(params) { + const generator = this._createGenerator(params); + + // Preserve special methods that Claude Code SDK provides + generator.interrupt = () => { + console.log('[Mock] interrupt() called'); + }; + + generator.setPermissionMode = mode => { + console.log('[Mock] setPermissionMode() called with:', mode); + }; + + return generator; + } + + async *_createGenerator(params) { + const model = params.options?.model || 'claude-sonnet-4-20250514'; + const sessionId = `sess_${Math.random().toString(36).substr(2, 9)}`; + const scenarioName = params.options?.scenario || 'basic'; + + // Get scenario or use default + const scenario = this.scenarios[scenarioName] || this._getBasicScenario(params); + + // Yield messages with small delays to simulate streaming + for (const message of scenario.messages) { + // Add small delay to simulate network + await new Promise(resolve => setTimeout(resolve, message.delay || 5)); + + // Inject session info and model where appropriate + if (message.type === 'system') { + yield { ...message, session_id: sessionId, model }; + } else if (message.type === 'assistant' && message.message) { + // Inject model into assistant message if not present + const messageData = message.message; + if (!messageData.model) { + messageData.model = model; + } + yield message; + } else { + yield message; + } + } + } + + _getBasicScenario(params) { + const responseId = `resp_${Date.now()}`; + const usage = { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 5, + cache_read_input_tokens: 3, + }; + + return { + messages: [ + // Session initialization + { + type: 'system', + session_id: 'will-be-replaced', + model: 'will-be-replaced', + conversation_history: params.inputMessages || [], + }, + // Assistant response + { + type: 'assistant', + message: { + id: responseId, + model: 'will-be-replaced', + role: 'assistant', + content: [{ type: 'text', text: 'I can help you with that.' }], + stop_reason: 'end_turn', + usage, + }, + }, + // Final result (includes usage for final tallying) + { + type: 'result', + result: 'I can help you with that.', + usage, + }, + ], + }; + } +} + +/** + * Predefined scenarios for different test cases + */ +export const SCENARIOS = { + basic: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + { + type: 'assistant', + message: { + id: 'resp_basic_123', + role: 'assistant', + content: [{ type: 'text', text: 'Hello! How can I help you today?' }], + stop_reason: 'end_turn', + usage: { + input_tokens: 15, + output_tokens: 25, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 10, + }, + }, + }, + { + type: 'result', + result: 'Hello! How can I help you today?', + usage: { + input_tokens: 15, + output_tokens: 25, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 10, + }, + }, + ], + }, + + withTools: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + // First LLM turn - makes a tool call + { + type: 'assistant', + message: { + id: 'resp_tool_1', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me read that file for you.' }, + { + type: 'tool_use', + id: 'tool_read_1', + name: 'Read', + input: { file_path: '/test.txt' }, + }, + ], + stop_reason: 'tool_use', + usage: { + input_tokens: 20, + output_tokens: 15, + cache_creation_input_tokens: 5, + cache_read_input_tokens: 0, + }, + }, + }, + // Tool result comes back + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_read_1', + content: 'File contents: Hello World', + }, + ], + }, + }, + // Second LLM turn - processes tool result + { + type: 'assistant', + message: { + id: 'resp_tool_2', + role: 'assistant', + content: [{ type: 'text', text: 'The file contains "Hello World".' }], + stop_reason: 'end_turn', + usage: { + input_tokens: 30, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 15, + }, + }, + }, + { + type: 'result', + result: 'The file contains "Hello World".', + usage: { + input_tokens: 50, // Cumulative: 20 + 30 + output_tokens: 35, // Cumulative: 15 + 20 + cache_creation_input_tokens: 5, + cache_read_input_tokens: 15, + }, + }, + ], + }, + + multipleTools: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + // First tool call - Glob + { + type: 'assistant', + message: { + id: 'resp_multi_1', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me find the JavaScript files.' }, + { + type: 'tool_use', + id: 'tool_glob_1', + name: 'Glob', + input: { pattern: '*.js' }, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 10, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_glob_1', + content: 'test.js\nindex.js', + }, + ], + }, + }, + // Second tool call - Read + { + type: 'assistant', + message: { + id: 'resp_multi_2', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me read the first file.' }, + { + type: 'tool_use', + id: 'tool_read_1', + name: 'Read', + input: { file_path: 'test.js' }, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 15, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 5 }, + }, + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_read_1', + content: 'console.log("test")', + }, + ], + }, + }, + // Final response + { + type: 'assistant', + message: { + id: 'resp_multi_3', + role: 'assistant', + content: [{ type: 'text', text: 'Found 2 JavaScript files.' }], + stop_reason: 'end_turn', + usage: { input_tokens: 20, output_tokens: 15, cache_creation_input_tokens: 0, cache_read_input_tokens: 10 }, + }, + }, + { + type: 'result', + result: 'Found 2 JavaScript files.', + usage: { + input_tokens: 45, // Cumulative: 10 + 15 + 20 + output_tokens: 35, // Cumulative: 10 + 10 + 15 + cache_creation_input_tokens: 0, + cache_read_input_tokens: 15, // Cumulative: 0 + 5 + 10 + }, + }, + ], + }, + + extensionTools: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + { + type: 'assistant', + message: { + id: 'resp_ext_1', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me search for that.' }, + { + type: 'tool_use', + id: 'tool_search_1', + name: 'WebSearch', + input: { query: 'Sentry error tracking' }, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 15, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_search_1', + content: 'Found 3 results about Sentry', + }, + ], + }, + }, + { + type: 'assistant', + message: { + id: 'resp_ext_2', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me fetch the main page.' }, + { + type: 'tool_use', + id: 'tool_fetch_1', + name: 'WebFetch', + input: { url: 'https://sentry.io' }, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 20, output_tokens: 15, cache_creation_input_tokens: 0, cache_read_input_tokens: 5 }, + }, + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_fetch_1', + content: '...', + }, + ], + }, + }, + { + type: 'assistant', + message: { + id: 'resp_ext_3', + role: 'assistant', + content: [{ type: 'text', text: 'Sentry is an error tracking platform.' }], + stop_reason: 'end_turn', + usage: { input_tokens: 25, output_tokens: 20, cache_creation_input_tokens: 0, cache_read_input_tokens: 10 }, + }, + }, + { + type: 'result', + result: 'Sentry is an error tracking platform.', + usage: { + input_tokens: 60, // Cumulative: 15 + 20 + 25 + output_tokens: 45, // Cumulative: 10 + 15 + 20 + cache_creation_input_tokens: 0, + cache_read_input_tokens: 15, // Cumulative: 0 + 5 + 10 + }, + }, + ], + }, + + agentError: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + // Error during agent operation + { + type: 'error', + error: new Error('Agent initialization failed'), + code: 'AGENT_INIT_ERROR', + delay: 10, + }, + ], + }, + + llmError: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + // Error during LLM call + { + type: 'error', + error: new Error('Rate limit exceeded'), + code: 'RATE_LIMIT_ERROR', + statusCode: 429, + delay: 10, + }, + ], + }, + + toolError: { + messages: [ + { + type: 'system', + session_id: 'will-be-replaced', + conversation_history: [], + }, + { + type: 'assistant', + message: { + id: 'resp_tool_err_1', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me run that command.' }, + { + type: 'tool_use', + id: 'tool_bash_1', + name: 'Bash', + input: { command: 'invalid_command' }, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 10, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + }, + }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_bash_1', + content: 'Command not found: invalid_command', + is_error: true, + }, + ], + }, + }, + { + type: 'assistant', + message: { + id: 'resp_tool_err_2', + role: 'assistant', + content: [{ type: 'text', text: 'The command failed to execute.' }], + stop_reason: 'end_turn', + usage: { input_tokens: 15, output_tokens: 15, cache_creation_input_tokens: 0, cache_read_input_tokens: 5 }, + }, + }, + { + type: 'result', + result: 'The command failed to execute.', + usage: { + input_tokens: 25, // Cumulative: 10 + 15 + output_tokens: 25, // Cumulative: 10 + 15 + cache_creation_input_tokens: 0, + cache_read_input_tokens: 5, + }, + }, + ], + }, +}; + +/** + * Helper to create a mock SDK instance with predefined scenarios + */ +export function createMockSdk() { + return new MockClaudeAgentSdk(SCENARIOS); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-errors.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-errors.mjs new file mode 100644 index 000000000000..8adb5b693c0e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-errors.mjs @@ -0,0 +1,88 @@ +/* eslint-disable no-console */ +import * as Sentry from '@sentry/node'; +import { createMockSdk } from './mock-server.mjs'; + +// This scenario tests error handling: +// - Agent initialization errors +// - LLM errors (rate limits, API errors) +// - Tool execution errors +// - Error span attributes and status + +async function run() { + const mockSdk = createMockSdk(); + + // Test agent initialization error + console.log('[Test] Running agent initialization error...'); + try { + const query1 = mockSdk.query({ + prompt: 'This will fail at agent init', + options: { model: 'claude-sonnet-4-20250514', scenario: 'agentError' }, + }); + + for await (const message of query1) { + console.log('[Message]', message.type); + if (message.type === 'error') { + throw message.error; + } + } + } catch (error) { + console.log('[Error caught]', error.message); + console.log('[Test] Agent error handled\n'); + } + + // Test LLM error (rate limit) + console.log('[Test] Running LLM error (rate limit)...'); + try { + const query2 = mockSdk.query({ + prompt: 'This will fail during LLM call', + options: { model: 'claude-sonnet-4-20250514', scenario: 'llmError' }, + }); + + for await (const message of query2) { + console.log('[Message]', message.type); + if (message.type === 'error') { + console.log('[Error details]', { + message: message.error.message, + code: message.code, + statusCode: message.statusCode, + }); + throw message.error; + } + } + } catch (error) { + console.log('[Error caught]', error.message); + console.log('[Test] LLM error handled\n'); + } + + // Test tool execution error + console.log('[Test] Running tool execution error...'); + const query3 = mockSdk.query({ + prompt: 'Run a command that will fail', + options: { model: 'claude-sonnet-4-20250514', scenario: 'toolError' }, + }); + + let toolErrorSeen = false; + for await (const message of query3) { + console.log('[Message]', message.type); + if (message.type === 'tool_result' && message.status === 'error') { + console.log('[Tool Error]', message.toolName, '-', message.error); + toolErrorSeen = true; + } else if (message.type === 'agent_complete') { + console.log('[Agent Complete]', message.result); + } + } + + if (toolErrorSeen) { + console.log('[Test] Tool error recorded successfully'); + } + console.log('[Test] Tool error scenario complete\n'); + + // Allow spans to be sent + await Sentry.flush(2000); + console.log('[Test] All error scenarios complete'); +} + +run().catch(error => { + console.error('[Fatal error]', error); + process.exit(1); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-simple.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-simple.mjs new file mode 100644 index 000000000000..08497a0e5188 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-simple.mjs @@ -0,0 +1,86 @@ +/* eslint-disable no-console */ +import { patchClaudeCodeQuery } from '@sentry/node'; +import * as Sentry from '@sentry/node'; + +// Very simple scenario to debug test infrastructure +// Uses correct Claude Agent SDK message format + +class SimpleMockSdk { + async *query(params) { + console.log('[Mock] Query called with model:', params.options?.model); + + const usage = { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }; + + // System message with session ID + yield { + type: 'system', + session_id: 'sess_test123', + model: 'claude-sonnet-4-20250514', + conversation_history: params.inputMessages || [], + }; + + // Small delay + await new Promise(resolve => setTimeout(resolve, 10)); + + // Assistant message (LLM response) + yield { + type: 'assistant', + message: { + id: 'resp_test456', + model: 'claude-sonnet-4-20250514', + role: 'assistant', + content: [{ type: 'text', text: 'Test response' }], + stop_reason: 'end_turn', + usage, + }, + }; + + // Result message (includes usage for final stats) + yield { type: 'result', result: 'Test response', usage }; + + console.log('[Mock] Query generator complete'); + } +} + +async function run() { + console.log('[Test] Starting simple scenario...'); + + const mockSdk = new SimpleMockSdk(); + const originalQuery = mockSdk.query.bind(mockSdk); + + console.log('[Test] Patching query function...'); + const patchedQuery = patchClaudeCodeQuery(originalQuery, { + agentName: 'claude-code', + }); + + console.log('[Test] Running patched query...'); + const query = patchedQuery({ + prompt: 'Test', + inputMessages: [{ role: 'user', content: 'Test' }], + options: { model: 'claude-sonnet-4-20250514' }, + }); + + let messageCount = 0; + for await (const message of query) { + messageCount++; + console.log(`[Test] Message ${messageCount}:`, message.type); + } + + console.log(`[Test] Received ${messageCount} messages`); + console.log('[Test] Flushing Sentry...'); + + await Sentry.flush(2000); + + console.log('[Test] Complete'); +} + +run().catch(error => { + console.error('[Fatal error]', error); + console.error(error.stack); + process.exit(1); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-tools.mjs new file mode 100644 index 000000000000..49f45bd44d45 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario-tools.mjs @@ -0,0 +1,72 @@ +/* eslint-disable no-console */ +import * as Sentry from '@sentry/node'; +import { createMockSdk } from './mock-server.mjs'; + +// This scenario specifically tests tool execution: +// - Function tools (Read, Bash, Glob, etc.) +// - Extension tools (WebSearch, WebFetch) +// - Tool input/output recording +// - Tool type classification + +async function run() { + const mockSdk = createMockSdk(); + + // Test function tools + console.log('[Test] Running with function tools (Read)...'); + const query1 = mockSdk.query({ + prompt: 'Read the file', + options: { model: 'claude-sonnet-4-20250514', scenario: 'withTools' }, + }); + + for await (const message of query1) { + if (message.type === 'llm_tool_call') { + console.log('[Tool Call]', message.toolName, '- Type: function'); + } else if (message.type === 'tool_result') { + console.log('[Tool Result]', message.toolName, '- Status:', message.status); + } + } + + console.log('[Test] Function tools complete\n'); + + // Test multiple tools in sequence + console.log('[Test] Running with multiple tools...'); + const query2 = mockSdk.query({ + prompt: 'Find and read JavaScript files', + options: { model: 'claude-sonnet-4-20250514', scenario: 'multipleTools' }, + }); + + const toolCalls = []; + for await (const message of query2) { + if (message.type === 'llm_tool_call') { + toolCalls.push(message.toolName); + console.log('[Tool Call]', message.toolName); + } + } + + console.log('[Test] Used tools:', toolCalls.join(', ')); + console.log('[Test] Multiple tools complete\n'); + + // Test extension tools + console.log('[Test] Running with extension tools...'); + const query3 = mockSdk.query({ + prompt: 'Search the web', + options: { model: 'claude-sonnet-4-20250514', scenario: 'extensionTools' }, + }); + + for await (const message of query3) { + if (message.type === 'llm_tool_call') { + console.log('[Tool Call]', message.toolName, '- Type: extension'); + } + } + + console.log('[Test] Extension tools complete\n'); + + // Allow spans to be sent + await Sentry.flush(2000); + console.log('[Test] All tool scenarios complete'); +} + +run().catch(error => { + console.error('[Fatal error]', error); + process.exit(1); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario.mjs new file mode 100644 index 000000000000..fc4a2a8f7005 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/scenario.mjs @@ -0,0 +1,126 @@ +/* eslint-disable no-console */ +import { patchClaudeCodeQuery } from '@sentry/node'; +import * as Sentry from '@sentry/node'; +import { createMockSdk } from './mock-server.mjs'; + +// This scenario tests basic agent invocation with manual patching +// The integration uses OpenTelemetry auto-instrumentation, but for testing +// we need to manually patch the mock SDK + +async function run() { + const mockSdk = createMockSdk(); + + // Manually patch the query function + const originalQuery = mockSdk.query.bind(mockSdk); + const patchedQuery = patchClaudeCodeQuery(originalQuery, { + agentName: 'claude-code', + }); + + // Basic query + console.log('[Test] Running basic agent invocation...'); + const query1 = patchedQuery({ + prompt: 'What is the capital of France?', + inputMessages: [{ role: 'user', content: 'What is the capital of France?' }], + options: { model: 'claude-sonnet-4-20250514', scenario: 'basic' }, + }); + + for await (const message of query1) { + console.log('[Message]', message.type); + if (message.type === 'llm_text') { + console.log('[LLM Text]', message.text); + } + } + + console.log('[Test] Basic invocation complete\n'); + + // Query with tool usage + console.log('[Test] Running agent invocation with tools...'); + const query2 = patchedQuery({ + prompt: 'Read the test file', + inputMessages: [{ role: 'user', content: 'Read the test file' }], + options: { model: 'claude-sonnet-4-20250514', scenario: 'withTools' }, + }); + + for await (const message of query2) { + console.log('[Message]', message.type); + if (message.type === 'llm_text') { + console.log('[LLM Text]', message.text); + } else if (message.type === 'llm_tool_call') { + console.log('[Tool Call]', message.toolName, message.toolInput); + } else if (message.type === 'tool_result') { + console.log('[Tool Result]', message.toolName, message.status); + } + } + + console.log('[Test] Tool invocation complete\n'); + + // Query with extension tools (WebSearch, WebFetch) + console.log('[Test] Running agent invocation with extension tools...'); + const query3 = patchedQuery({ + prompt: 'Search for information about Sentry', + inputMessages: [{ role: 'user', content: 'Search for information about Sentry' }], + options: { model: 'claude-sonnet-4-20250514', scenario: 'extensionTools' }, + }); + + for await (const message of query3) { + console.log('[Message]', message.type); + if (message.type === 'llm_tool_call') { + console.log('[Tool Call]', message.toolName, 'type:', getToolType(message.toolName)); + } + } + + console.log('[Test] Extension tools invocation complete\n'); + + // Test error handling + console.log('[Test] Running agent invocation with LLM error...'); + try { + const query4 = patchedQuery({ + prompt: 'This will fail', + inputMessages: [{ role: 'user', content: 'This will fail' }], + options: { model: 'claude-sonnet-4-20250514', scenario: 'llmError' }, + }); + + for await (const message of query4) { + console.log('[Message]', message.type); + if (message.type === 'error') { + throw message.error; + } + } + } catch (error) { + console.log('[Error caught]', error.message); + } + + console.log('[Test] Error handling complete\n'); + + // Allow spans to be sent + await Sentry.flush(2000); + console.log('[Test] All scenarios complete'); +} + +function getToolType(toolName) { + const functionTools = new Set([ + 'Bash', + 'BashOutput', + 'KillShell', + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'Task', + 'ExitPlanMode', + 'TodoWrite', + 'NotebookEdit', + 'SlashCommand', + ]); + const extensionTools = new Set(['WebSearch', 'WebFetch']); + + if (functionTools.has(toolName)) return 'function'; + if (extensionTools.has(toolName)) return 'extension'; + return 'function'; +} + +run().catch(error => { + console.error('[Fatal error]', error); + process.exit(1); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/test-simple.ts b/dev-packages/node-integration-tests/suites/tracing/claude-code/test-simple.ts new file mode 100644 index 000000000000..a6757608bd7f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/test-simple.ts @@ -0,0 +1,22 @@ +import { afterAll, describe } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('Claude Code Agent SDK integration - Simple', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + // Very basic expectation - just check that a transaction is created + createEsmAndCjsTests(__dirname, 'scenario-simple.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates a transaction with claude-code spans', async () => { + await createRunner() + .expect({ + transaction: { + transaction: 'invoke_agent claude-code', + }, + }) + .start() + .completed(); + }, 20000); // Increase timeout + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/claude-code/test.ts b/dev-packages/node-integration-tests/suites/tracing/claude-code/test.ts new file mode 100644 index 000000000000..cca65fa29ca4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/claude-code/test.ts @@ -0,0 +1,197 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('Claude Code Agent SDK integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + // Expected span structure for basic invocation (sendDefaultPii: false) + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'invoke_agent claude-code', + spans: expect.arrayContaining([ + // LLM chat span (child of agent span) + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.ai.claude-code', + 'sentry.op': 'gen_ai.chat', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-sonnet-4-20250514', + 'gen_ai.operation.name': 'chat', + 'gen_ai.response.id': expect.stringMatching(/^resp_/), + 'gen_ai.response.model': 'claude-sonnet-4-20250514', + 'gen_ai.usage.input_tokens': expect.any(Number), + 'gen_ai.usage.output_tokens': expect.any(Number), + 'gen_ai.usage.total_tokens': expect.any(Number), + // NO response.text (sendDefaultPii: false) + }), + description: expect.stringMatching(/^chat claude-sonnet/), + op: 'gen_ai.chat', + origin: 'auto.ai.claude-code', + status: 'ok', + }), + ]), + }; + + // Expected span structure with PII enabled + const EXPECTED_TRANSACTION_WITH_PII = { + transaction: 'invoke_agent claude-code', + spans: expect.arrayContaining([ + // LLM span with response text + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.response.text': expect.stringContaining('Hello!'), + 'gen_ai.response.id': expect.stringMatching(/^resp_/), + 'gen_ai.usage.input_tokens': expect.any(Number), + }), + op: 'gen_ai.chat', + status: 'ok', + }), + ]), + }; + + // Expected spans with tools + const EXPECTED_TRANSACTION_WITH_TOOLS = { + transaction: 'invoke_agent claude-code', + spans: expect.arrayContaining([ + // Tool execution span - Read (function type) + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.ai.claude-code', + 'gen_ai.tool.name': 'Read', + 'gen_ai.tool.type': 'function', + }), + description: 'execute_tool Read', + op: 'gen_ai.execute_tool', + origin: 'auto.ai.claude-code', + status: 'ok', + }), + + // LLM chat spans (should have multiple from the conversation) + expect.objectContaining({ + op: 'gen_ai.chat', + status: 'ok', + }), + ]), + }; + + // Expected spans with extension tools + const EXPECTED_TRANSACTION_WITH_EXTENSION_TOOLS = { + transaction: 'invoke_agent claude-code', + spans: expect.arrayContaining([ + // WebSearch - extension type + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.tool.name': 'WebSearch', + 'gen_ai.tool.type': 'extension', + }), + description: 'execute_tool WebSearch', + op: 'gen_ai.execute_tool', + }), + + // WebFetch - extension type + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.tool.name': 'WebFetch', + 'gen_ai.tool.type': 'extension', + }), + description: 'execute_tool WebFetch', + op: 'gen_ai.execute_tool', + }), + ]), + }; + + // Expected error handling + const EXPECTED_ERROR_EVENT = { + exception: { + values: [ + expect.objectContaining({ + type: 'Error', + value: expect.stringMatching(/Rate limit exceeded|Agent initialization failed/), + mechanism: { + type: 'auto.ai.claude-code', + handled: false, + }, + }), + ], + }, + }; + + // Basic tests with default PII settings + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates claude-code related spans with sendDefaultPii: false', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }).start().completed(); + }); + }); + + // Tests with PII enabled + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records input messages and response text with sendDefaultPii: true', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_WITH_PII }).start().completed(); + }); + }); + + // Tests with custom options + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + test('respects custom recordInputs/recordOutputs options', async () => { + await createRunner() + .expect({ + transaction: { + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + // recordInputs: true + 'gen_ai.request.messages': expect.any(String), + }), + op: 'gen_ai.invoke_agent', + }), + expect.objectContaining({ + data: expect.not.objectContaining({ + // recordOutputs: false + 'gen_ai.response.text': expect.anything(), + }), + op: 'gen_ai.chat', + }), + ]), + }, + }) + .start() + .completed(); + }); + }); + + // Tool execution tests + createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates tool execution spans with correct types', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_WITH_TOOLS }).start().completed(); + }); + + test('classifies extension tools correctly', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_WITH_EXTENSION_TOOLS }).start().completed(); + }); + }); + + // Error handling tests + createEsmAndCjsTests(__dirname, 'scenario-errors.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures errors with correct mechanism type', async () => { + await createRunner().expect({ event: EXPECTED_ERROR_EVENT }).start().completed(); + }); + + test('sets span status to error on failure', async () => { + await createRunner() + .expect({ + transaction: { + spans: expect.arrayContaining([ + expect.objectContaining({ + op: 'gen_ai.invoke_agent', + status: 'internal_error', + }), + ]), + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e18ea294f182..b0413cebad57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -157,6 +157,24 @@ 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, +} from './tracing/ai/gen-ai-attributes'; export type { AnthropicAiClient, AnthropicAiOptions, diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index e2808d5f2642..46d2917cf851 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -179,6 +179,30 @@ export const GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE = 'gen_ai.usage.input_to */ export const GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE = 'gen_ai.invoke_agent'; +// ============================================================================= +// AI AGENT ATTRIBUTES +// ============================================================================= + +/** + * The name of the 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/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index 7c92fecd7834..b1cba40834b8 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -27,6 +27,9 @@ 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 claudeCodeAgentSdkIntegration: typeof serverSdk.claudeCodeAgentSdkIntegration; + export declare const getDefaultIntegrations: (options: Options) => Integration[]; export declare const defaultStackParser: StackParser; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 7dc533e171b1..c0bfae9083eb 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'; @@ -37,6 +37,10 @@ import { handleOnSpanStart } from './handleOnSpanStart'; 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 bb655b87fc42..701e2edf48c3 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/index.ts b/packages/node/src/integrations/tracing/claude-code/index.ts new file mode 100644 index 000000000000..5e64d2e437a3 --- /dev/null +++ b/packages/node/src/integrations/tracing/claude-code/index.ts @@ -0,0 +1,124 @@ +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node-core'; +import { SentryClaudeCodeAgentSdkInstrumentation } from './otel-instrumentation'; +import type { ClaudeCodeOptions } from './types'; + +export type { ClaudeCodeOptions } from './types'; + +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, + options, + setupOnce() { + instrumentClaudeCodeAgentSdk(options); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for the Claude Code Agent SDK. + * + * This integration automatically instruments the `query` function from + * `@anthropic-ai/claude-agent-sdk` to capture telemetry data following + * OpenTelemetry Semantic Conventions for Generative AI. + * + * **Important**: Sentry must be initialized BEFORE importing `@anthropic-ai/claude-agent-sdk`. + * + * @example + * ```typescript + * // Initialize Sentry FIRST + * import * as Sentry from '@sentry/node'; + * + * Sentry.init({ + * dsn: 'your-dsn', + * integrations: [ + * Sentry.claudeCodeAgentSdkIntegration({ + * recordInputs: true, + * recordOutputs: true + * }) + * ], + * }); + * + * // THEN import the SDK - it will be automatically instrumented! + * import { query } from '@anthropic-ai/claude-agent-sdk'; + * + * // Use query as normal - spans are created automatically + * for await (const message of query({ + * prompt: 'Hello', + * options: { model: 'claude-sonnet-4-20250514' } + * })) { + * console.log(message); + * } + * ``` + * + * ## Captured Telemetry + * + * This integration captures: + * - Agent invocation spans (`gen_ai.invoke_agent`) + * - LLM chat spans (`gen_ai.chat`) + * - Tool execution spans (`gen_ai.execute_tool`) + * - Token usage, model info, and session tracking + * + * ## Options + * + * - `recordInputs`: Whether to record prompt messages (default: respects `sendDefaultPii` client option) + * - `recordOutputs`: Whether to record response text, tool calls, and outputs (default: respects `sendDefaultPii` client option) + * - `agentName`: Custom agent name for differentiation (default: 'claude-code') + * + * ### Default Behavior + * + * 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.claudeCodeAgentSdkIntegration({ + * recordInputs: true, + * recordOutputs: true + * }) + * ], + * }); + * + * // Never record inputs/outputs regardless of sendDefaultPii + * Sentry.init({ + * sendDefaultPii: true, + * integrations: [ + * Sentry.claudeCodeAgentSdkIntegration({ + * recordInputs: false, + * recordOutputs: false + * }) + * ], + * }); + * + * // Custom agent name + * Sentry.init({ + * integrations: [ + * Sentry.claudeCodeAgentSdkIntegration({ + * agentName: 'my-coding-assistant' + * }) + * ], + * }); + * ``` + * + * @see https://docs.sentry.io/platforms/javascript/guides/node/ai-monitoring/ + */ +export const claudeCodeAgentSdkIntegration = defineIntegration(_claudeCodeAgentSdkIntegration); + +// Export for testing purposes only +export { patchClaudeCodeQuery } from './instrumentation'; diff --git a/packages/node/src/integrations/tracing/claude-code/instrumentation.ts b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts new file mode 100644 index 000000000000..d738357dc006 --- /dev/null +++ b/packages/node/src/integrations/tracing/claude-code/instrumentation.ts @@ -0,0 +1,460 @@ +/* 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'; + +/** + * Maps Claude Code tool names to OpenTelemetry tool types. + * + * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/ + * @see https://platform.claude.com/docs/en/agent-sdk/typescript + * @param toolName - The name of the tool (e.g., 'Bash', 'Read', 'WebSearch') + * @returns The OpenTelemetry tool type: 'function', 'extension', or 'datastore' + */ +function getToolType(toolName: string): 'function' | 'extension' | 'datastore' { + // Client-side execution tools - functions that run on the client + const functionTools = new Set([ + // Shell/process tools + 'Bash', + 'BashOutput', + 'KillBash', + + // File operations + 'Read', + 'Write', + 'Edit', + 'NotebookEdit', + + // File search + 'Glob', + 'Grep', + + // Agent control + 'Task', + 'ExitPlanMode', + 'EnterPlanMode', + 'TodoWrite', + + // User interaction + 'AskUserQuestion', + 'SlashCommand', + 'Skill', + ]); + + // Agent-side API calls - external service integrations + const extensionTools = new Set(['WebSearch', 'WebFetch', 'ListMcpResources', 'ReadMcpResource']); + + // Data access tools - database/structured data operations + // (Currently none in Claude Code, but future-proofing) + const datastoreTools = new Set([]); + + if (functionTools.has(toolName)) return 'function'; + if (extensionTools.has(toolName)) return 'extension'; + if (datastoreTools.has(toolName)) return 'datastore'; + + // Default to function for unknown tools (safest assumption) + return 'function'; +} + +/** + * 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'; + + // Parse query arguments + const [queryParams] = args as [Record]; + const { options: queryOptions, inputMessages } = queryParams || {}; + const model = (queryOptions as Record)?.model ?? 'unknown'; + + // Create original query instance + const originalQueryInstance = queryFunction.apply(this, args); + + // Create instrumented generator + const instrumentedGenerator = _createInstrumentedGenerator(originalQueryInstance, model as string, { + recordInputs, + recordOutputs, + inputMessages, + agentName, + }); + + // Preserve Query interface methods + 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; + inputMessages?: unknown; + agentName?: string; + }, +): AsyncGenerator { + const agentName = instrumentationOptions.agentName ?? 'claude-code'; + + return startSpanManual( + { + name: `invoke_agent ${agentName}`, + op: 'gen_ai.invoke_agent', + attributes: { + [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', + }, + }, + // eslint-disable-next-line complexity + async function* (span: Span) { + // State accumulation + let sessionId: string | null = null; + let currentLLMSpan: Span | null = null; + let currentTurnContent = ''; + let currentTurnTools: unknown[] = []; + let currentTurnId: string | null = null; + let currentTurnModel: string | null = null; + let currentTurnStopReason: string | null = null; + let inputMessagesCaptured = false; + let finalResult: string | null = null; + let previousLLMSpan: Span | null = null; + let previousTurnTools: unknown[] = []; + + try { + for await (const message of originalQuery) { + const msg = message as Record; + + // Extract session ID and available tools from system message + if (msg.type === 'system') { + if (msg.session_id) { + sessionId = msg.session_id as string; + } + + // Capture available tools from system init message + if (msg.subtype === 'init' && Array.isArray(msg.tools)) { + span.setAttributes({ + [GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE]: JSON.stringify(msg.tools), + }); + } + + if (!inputMessagesCaptured && instrumentationOptions.recordInputs && msg.conversation_history) { + span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(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_SYSTEM_ATTRIBUTE]: 'anthropic', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: model, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', + }, + }, + (childSpan: Span) => { + if (instrumentationOptions.recordInputs && instrumentationOptions.inputMessages) { + childSpan.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString( + instrumentationOptions.inputMessages, + ), + }); + } + return childSpan; + }, + ); + }); + + currentTurnContent = ''; + currentTurnTools = []; + } + + // 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; + } + if ((msg.message as Record)?.stop_reason) { + currentTurnStopReason = (msg.message as Record).stop_reason as string; + } + } + + // Handle result messages + if (msg.type === 'result') { + if (msg.result) { + finalResult = msg.result as string; + } + + // Close previous LLM span + if (previousLLMSpan) { + previousLLMSpan.setStatus({ code: 1 }); + previousLLMSpan.end(); + previousLLMSpan = null; + previousTurnTools = []; + } + + // Finalize current LLM span + if (currentLLMSpan) { + if (instrumentationOptions.recordOutputs && currentTurnContent) { + currentLLMSpan.setAttributes({ + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: currentTurnContent, + }); + } + + if (instrumentationOptions.recordOutputs && currentTurnTools.length > 0) { + currentLLMSpan.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(currentTurnTools), + }); + } + + if (currentTurnId) { + currentLLMSpan.setAttributes({ + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: currentTurnId, + }); + } + if (currentTurnModel) { + currentLLMSpan.setAttributes({ + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: currentTurnModel, + }); + } + if (currentTurnStopReason) { + currentLLMSpan.setAttributes({ + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify([currentTurnStopReason]), + }); + } + + if (msg.usage) { + const usage = msg.usage as Record; + setTokenUsageAttributes( + currentLLMSpan, + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + ); + } + + currentLLMSpan.setStatus({ code: 1 }); + currentLLMSpan.end(); + + previousLLMSpan = currentLLMSpan; + previousTurnTools = currentTurnTools; + + currentLLMSpan = null; + currentTurnContent = ''; + currentTurnTools = []; + currentTurnId = null; + currentTurnModel = null; + currentTurnStopReason = null; + } + } + + // Handle tool results + if (msg.type === 'user' && (msg.message as Record)?.content) { + const content = (msg.message as Record).content as unknown[]; + const toolResults = Array.isArray(content) + ? content.filter(c => (c as Record).type === 'tool_result') + : []; + + for (const toolResult of toolResults) { + const tr = toolResult as Record; + let matchingTool = currentTurnTools.find(t => (t as Record).id === tr.tool_use_id) as + | 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), + }); + } + + // Set span status explicitly + if (tr.is_error) { + toolSpan.setStatus({ code: 2, message: 'Tool execution error' }); + } else { + toolSpan.setStatus({ code: 1 }); // Explicit success status + } + }, + ); + }); + } + } + } + + yield message; + } + + if (instrumentationOptions.recordOutputs && finalResult) { + span.setAttributes({ + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: finalResult, + }); + } + + if (sessionId) { + span.setAttributes({ + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: sessionId, + }); + } + + span.setStatus({ code: 1 }); + } catch (error) { + // Capture exception to Sentry with proper metadata + captureException(error, { + mechanism: { + type: SENTRY_ORIGIN, + handled: false, + data: { + function: 'query', + }, + }, + }); + + span.setStatus({ code: 2, message: (error as Error).message }); + throw error; + } finally { + // Ensure all child spans are closed even if generator exits early + if (currentLLMSpan?.isRecording()) { + currentLLMSpan.setStatus({ code: 1 }); + currentLLMSpan.end(); + } + + if (previousLLMSpan?.isRecording()) { + previousLLMSpan.setStatus({ code: 1 }); + previousLLMSpan.end(); + } + + span.end(); + } + }, + ); +} diff --git a/packages/node/src/integrations/tracing/claude-code/otel-instrumentation.ts b/packages/node/src/integrations/tracing/claude-code/otel-instrumentation.ts new file mode 100644 index 000000000000..6c423e896a96 --- /dev/null +++ b/packages/node/src/integrations/tracing/claude-code/otel-instrumentation.ts @@ -0,0 +1,110 @@ +import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; +import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import { getClient, SDK_VERSION } from '@sentry/core'; +import { patchClaudeCodeQuery } from './instrumentation'; +import type { ClaudeCodeOptions } from './types'; + +const SUPPORTED_VERSIONS = ['>=0.1.0 <1.0.0']; + +type ClaudeCodeInstrumentationConfig = InstrumentationConfig & ClaudeCodeOptions; + +/** + * Represents the shape of the @anthropic-ai/claude-agent-sdk module exports. + */ +interface ClaudeAgentSdkModuleExports { + [key: string]: unknown; + query: (...args: unknown[]) => AsyncGenerator; +} + +/** + * OpenTelemetry instrumentation for the Claude Code Agent SDK. + * + * This instrumentation automatically patches the `query` function from + * `@anthropic-ai/claude-agent-sdk` to add Sentry tracing spans. + * + * It handles both ESM and CommonJS module formats. + */ +export class SentryClaudeCodeAgentSdkInstrumentation extends InstrumentationBase { + public constructor(config: ClaudeCodeInstrumentationConfig = {}) { + super('@sentry/instrumentation-claude-code-agent-sdk', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation by defining the module to be patched. + */ + public init(): InstrumentationModuleDefinition { + return new InstrumentationNodeModuleDefinition( + '@anthropic-ai/claude-agent-sdk', + SUPPORTED_VERSIONS, + this._patch.bind(this), + ); + } + + /** + * Patches the module exports to wrap the query function with instrumentation. + */ + private _patch(moduleExports: ClaudeAgentSdkModuleExports): ClaudeAgentSdkModuleExports { + const config = this.getConfig(); + const originalQuery = moduleExports.query; + + if (typeof originalQuery !== 'function') { + this._diag.warn('Could not find query function in @anthropic-ai/claude-agent-sdk'); + return moduleExports; + } + + // Create wrapped query function + const wrappedQuery = function (this: unknown, ...args: unknown[]): AsyncGenerator { + const client = getClient(); + const defaultPii = Boolean(client?.getOptions().sendDefaultPii); + + const options: ClaudeCodeOptions = { + recordInputs: config.recordInputs ?? defaultPii, + recordOutputs: config.recordOutputs ?? defaultPii, + agentName: config.agentName ?? 'claude-code', + }; + + // Use the existing patch logic + const instrumentedQuery = patchClaudeCodeQuery(originalQuery, options); + return instrumentedQuery.apply(this, args); + }; + + // Preserve function properties + Object.defineProperty(wrappedQuery, 'name', { value: originalQuery.name }); + Object.defineProperty(wrappedQuery, 'length', { value: originalQuery.length }); + + // Check if ESM module namespace object + // https://tc39.es/ecma262/#sec-module-namespace-objects + if (Object.prototype.toString.call(moduleExports) === '[object Module]') { + // ESM: Replace query export directly + // OTel's instrumentation makes these writable + try { + moduleExports.query = wrappedQuery; + } catch { + // If direct assignment fails, try defineProperty + Object.defineProperty(moduleExports, 'query', { + value: wrappedQuery, + writable: true, + configurable: true, + enumerable: true, + }); + } + + // Also patch default export if it has a query property + if (moduleExports.default && typeof moduleExports.default === 'object' && 'query' in moduleExports.default) { + try { + (moduleExports.default as Record).query = wrappedQuery; + } catch { + // Ignore if we can't patch default - this is expected in some cases + } + } + + return moduleExports; + } else { + // CJS: Return new object with patched query spread over original + return { + ...moduleExports, + query: wrappedQuery, + }; + } + } +} diff --git a/packages/node/src/integrations/tracing/claude-code/types.ts b/packages/node/src/integrations/tracing/claude-code/types.ts new file mode 100644 index 000000000000..10cba75f9183 --- /dev/null +++ b/packages/node/src/integrations/tracing/claude-code/types.ts @@ -0,0 +1,29 @@ +export interface ClaudeCodeOptions { + /** + * Whether to record prompt messages. + * Defaults to Sentry client's `sendDefaultPii` setting. + */ + recordInputs?: boolean; + + /** + * Whether to record response text, tool calls, and tool outputs. + * Defaults to Sentry client's `sendDefaultPii` setting. + */ + recordOutputs?: boolean; + + /** + * Custom agent name to use for this integration. + * This allows you to differentiate between multiple Claude Code agents in your application. + * Defaults to 'claude-code'. + * + * @example + * ```typescript + * Sentry.init({ + * integrations: [ + * Sentry.claudeCodeAgentSdkIntegration({ agentName: 'app-builder' }) + * ] + * }); + * ``` + */ + agentName?: string; +} 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, ]; }