UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

588 lines (497 loc) 17.3 kB
'use strict' const { tracingChannel } = require('dc-polyfill') const shimmer = require('../../datadog-shimmer') const { addHook, getHooks } = require('./helpers/instrument') const queryChannel = tracingChannel('orchestrion:@anthropic-ai/claude-agent-sdk:query') const stepCh = tracingChannel('apm:claude-agent-sdk:step') const llmCh = tracingChannel('apm:claude-agent-sdk:llm') const toolCh = tracingChannel('apm:claude-agent-sdk:tool') const LOCAL_LIFECYCLE_LOOKAHEAD = 4 const chunkEmitTimes = new WeakMap() function hasDownstreamSubscribers () { return queryChannel.asyncEnd.hasSubscribers || queryChannel.error.hasSubscribers || stepCh.start.hasSubscribers || llmCh.start.hasSubscribers || toolCh.start.hasSubscribers } function mergeHooks (userHooks, tracerHooks) { const merged = {} for (const event of Object.keys(tracerHooks)) { const userMatchers = userHooks?.[event] || [] merged[event] = [...userMatchers, ...tracerHooks[event]] } if (userHooks) { for (const event of Object.keys(userHooks)) { if (!merged[event]) merged[event] = userHooks[event] } } return merged } function getTool (sessionCtx, id) { let tool = sessionCtx.tools.get(id) if (!tool) { tool = { id } sessionCtx.tools.set(id, tool) } return tool } function buildTracerHooks (sessionCtx) { function onSessionStart (input) { sessionCtx.sessionId = input.session_id sessionCtx.source = input.source sessionCtx.cwd = input.cwd sessionCtx.transcriptPath = input.transcript_path sessionCtx.agentType = input.agent_type sessionCtx.permissionMode = sessionCtx.permissionMode || input.permission_mode return {} } function onSessionEnd (input) { sessionCtx.endReason = input.reason return {} } function onUserPromptSubmit (input) { sessionCtx.sessionId = sessionCtx.sessionId || input.session_id sessionCtx.prompt = sessionCtx.prompt || input.prompt return {} } function onStop (input) { sessionCtx.stopReason = input.stop_reason sessionCtx.lastAssistantMessage = input.last_assistant_message return {} } function onPreToolUse (input, toolUseId) { const id = toolUseId || input.tool_use_id if (!id) return {} Object.assign(getTool(sessionCtx, id), { id, name: input.tool_name, input: input.tool_input, sessionId: input.session_id, hookStartTime: Date.now(), }) return {} } function onPostToolUse (input, toolUseId) { const id = toolUseId || input.tool_use_id if (!id) return {} const tool = getTool(sessionCtx, id) Object.assign(tool, { id, name: input.tool_name || tool.name, input: input.tool_input === undefined ? tool.input : input.tool_input, output: input.tool_response, sessionId: input.session_id, hookFinishTime: Date.now(), }) return {} } function onPostToolUseFailure (input, toolUseId) { const id = toolUseId || input.tool_use_id if (!id) return {} const tool = getTool(sessionCtx, id) Object.assign(tool, { id, name: input.tool_name || tool.name, input: input.tool_input === undefined ? tool.input : input.tool_input, error: input.error, isInterrupt: input.is_interrupt, sessionId: input.session_id, hookFinishTime: Date.now(), }) return {} } function onSubagentStart (input) { const id = input.agent_id if (!id) return {} sessionCtx.subagents.set(id, { id, sessionId: input.session_id, agentType: input.agent_type, hookStartTime: Date.now(), }) return {} } function onSubagentStop (input) { const id = input.agent_id if (!id) return {} const subagent = sessionCtx.subagents.get(id) || { id } Object.assign(subagent, { sessionId: input.session_id, agentType: input.agent_type || subagent.agentType, transcriptPath: input.agent_transcript_path, output: input.last_assistant_message, hookFinishTime: Date.now(), }) sessionCtx.subagents.set(id, subagent) return {} } return { SessionStart: [{ hooks: [onSessionStart] }], SessionEnd: [{ hooks: [onSessionEnd] }], UserPromptSubmit: [{ hooks: [onUserPromptSubmit] }], Stop: [{ hooks: [onStop] }], PreToolUse: [{ hooks: [onPreToolUse] }], PostToolUse: [{ hooks: [onPostToolUse] }], PostToolUseFailure: [{ hooks: [onPostToolUseFailure] }], SubagentStart: [{ hooks: [onSubagentStart] }], SubagentStop: [{ hooks: [onSubagentStop] }], } } function onQueryStart (ctx) { if (!hasDownstreamSubscribers()) return const { arguments: args } = ctx const queryArg = args?.[0] if (!queryArg) return const options = queryArg.options || {} const prompt = queryArg.prompt const sessionCtx = { prompt: typeof prompt === 'string' ? prompt : undefined, model: options.model, resume: options.resume, maxTurns: options.maxTurns, permissionMode: options.permissionMode, tools: new Map(), subagents: new Map(), } args[0] = { ...queryArg, options: { ...options, hooks: mergeHooks(options.hooks, buildTracerHooks(sessionCtx)), }, } ctx.sessionCtx = sessionCtx ctx.claudeAgentSdkTracing = true } function buildStreamIndex (chunks) { const lifecycleByToolId = new Map() for (let idx = 0; idx < chunks.length; idx++) { const chunk = chunks[idx] if (chunk.type === 'system' && chunk.subtype === 'task_started' && chunk.tool_use_id) { const lifecycle = lifecycleByToolId.get(chunk.tool_use_id) || {} lifecycle.taskStartedChunk = chunk lifecycleByToolId.set(chunk.tool_use_id, lifecycle) } else if (chunk.type === 'user') { const content = chunk.message?.content if (!Array.isArray(content)) continue for (const block of content) { if (block.type === 'tool_result' && block.tool_use_id) { const lifecycle = lifecycleByToolId.get(block.tool_use_id) || {} if (lifecycle.toolResultIndex === undefined) lifecycle.toolResultIndex = idx lifecycleByToolId.set(block.tool_use_id, lifecycle) } } } } return lifecycleByToolId } function scanLocalLifecycle (chunks, startIndex, toolUseId, lifecycle) { lifecycle.taskStartedChunk = undefined lifecycle.toolResultIndex = undefined const scanEnd = Math.min(chunks.length, startIndex + LOCAL_LIFECYCLE_LOOKAHEAD) for (let idx = startIndex; idx < scanEnd; idx++) { const chunk = chunks[idx] if (chunk.type === 'system' && chunk.subtype === 'task_started' && chunk.tool_use_id === toolUseId) { lifecycle.taskStartedChunk = chunk } else if (chunk.type === 'user') { const content = chunk.message?.content if (!Array.isArray(content)) continue for (const block of content) { if (block.type === 'tool_result' && block.tool_use_id === toolUseId) { lifecycle.toolResultIndex = idx return lifecycle } } } } return lifecycle } function createStreamLookup (chunks) { let streamIndex const localLifecycle = {} return function getLifecycle (startIndex, toolUseId) { if (streamIndex) return streamIndex.get(toolUseId) || {} scanLocalLifecycle(chunks, startIndex, toolUseId, localLifecycle) if (localLifecycle.toolResultIndex !== undefined) return localLifecycle streamIndex = streamIndex || buildStreamIndex(chunks) const indexedLifecycle = streamIndex.get(toolUseId) return { taskStartedChunk: localLifecycle.taskStartedChunk || indexedLifecycle?.taskStartedChunk, toolResultIndex: indexedLifecycle?.toolResultIndex, } } } function getToolScanEnd (chunks, startIndex, toolUseId, toolResultIndex) { if (toolResultIndex !== undefined) return toolResultIndex + 1 for (let idx = startIndex; idx < chunks.length; idx++) { const chunk = chunks[idx] if (chunk.type === 'result') return idx if (chunk.type === 'assistant' && chunk.parent_tool_use_id !== toolUseId) return idx } return chunks.length } function getToolResultContent (chunk, toolUseId) { if (chunk?.type !== 'user') return const content = chunk.message?.content if (!Array.isArray(content)) return for (const block of content) { if (block.type === 'tool_result' && block.tool_use_id === toolUseId) return [block] } } /** * * @param {Array<Record<string, unknown>>} chunks * @param {number} startIndex * @param {string} toolUseId * @returns {number} index in chunks where the next step should start iteration */ function processTool (chunks, startIndex, toolUseId, sessionCtx, getLifecycle, lifecycle) { let chunkIndex = startIndex let stepIndex = 0 const tool = sessionCtx?.tools.get(toolUseId) const toolResultIndex = lifecycle.toolResultIndex const scanEnd = getToolScanEnd(chunks, startIndex, toolUseId, toolResultIndex) while (chunkIndex < scanEnd) { const chunk = chunks[chunkIndex] if (chunkIndex === toolResultIndex) return chunkIndex + 1 // only process steps for assistant chunks that belong to this subagent invocation if (chunk.type === 'assistant' && chunk.parent_tool_use_id === toolUseId) { let prevIdx = chunkIndex - 1 while (prevIdx >= 0 && chunks[prevIdx].type === 'system') prevIdx-- const prevChunk = prevIdx >= 0 ? chunks[prevIdx] : chunk const stepCtx = { stepIndex, startTime: chunkEmitTimes.get(prevChunk), parentToolUseId: toolUseId, sessionId: chunks[0]?.session_id, } chunkIndex = stepCh.traceSync(() => { const nextIdx = processStep( chunks, chunkIndex, stepCtx.startTime, toolUseId, undefined, stepCtx, sessionCtx, getLifecycle ) stepCtx.finishTime = chunkEmitTimes.get(chunks[Math.min(nextIdx - 1, chunks.length - 1)]) return nextIdx }, stepCtx) stepIndex++ } else { chunkIndex++ } } if (tool?.hookFinishTime) return scanEnd return chunks.length + 1 } /** * * @param {Array<Record<string, unknown>>} chunks * @param {number} startIndex * @returns {number} index in chunks where the next step should start iteration */ function processStep ( chunks, startIndex, stepStartTime, parentToolUseId = null, initialPrompt, stepCtx = null, sessionCtx = null, getLifecycle ) { for (let idx = startIndex; idx < chunks.length; idx++) { const chunk = chunks[idx] if (chunk.type !== 'assistant') continue const { id: messageId, model, usage } = chunk.message // collect all chunks belonging to the same message (parallel tool_uses arrive as separate chunks) const toolUses = [] let messageEndIdx = idx for (let j = idx; j < chunks.length; j++) { const c = chunks[j] if (c.type !== 'assistant' || c.message.id !== messageId) break if (c.message.content[0]?.type === 'tool_use') { toolUses.push({ toolUse: c.message.content[0], startTime: chunkEmitTimes.get(c) }) } messageEndIdx = j + 1 } if (stepCtx) { stepCtx.chunks = chunks stepCtx.llmStartIdx = idx stepCtx.llmEndIdx = messageEndIdx stepCtx.toolOutputs = [] } llmCh.traceSync(() => {}, { model, usage, startTime: stepStartTime, finishTime: chunkEmitTimes.get(chunks[messageEndIdx - 1]), chunks, llmStartIdx: idx, llmEndIdx: messageEndIdx, parentToolUseId, initialPrompt, sessionId: chunks[0]?.session_id, }) if (toolUses.length === 0) { return messageEndIdx } // for parallel tool calls, each processTool starts from the same messageEndIdx // and independently scans for its own tool_result; take the max to advance past all results let nextIdx = messageEndIdx for (const { toolUse: { id, name, input }, startTime: toolUseStartTime } of toolUses) { const hookTool = sessionCtx?.tools.get(id) const lifecycle = getLifecycle(messageEndIdx, id) // Agent tools emit a system:task_started chunk with a more precise start time const taskStartedChunk = lifecycle.taskStartedChunk const toolCtx = { id, name: hookTool?.name || name, input: hookTool?.input || input, startTime: hookTool?.hookStartTime || (taskStartedChunk ? chunkEmitTimes.get(taskStartedChunk) : toolUseStartTime), sessionId: chunks[0]?.session_id, } const toolEndIdx = toolCh.traceSync(() => { const endIdx = processTool(chunks, messageEndIdx, id, sessionCtx, getLifecycle, lifecycle) const resultChunk = chunks[endIdx - 1] if (hookTool?.isInterrupt) toolCtx.isInterrupt = hookTool.isInterrupt if (hookTool?.error) { toolCtx.error = hookTool.error toolCh.error.publish(toolCtx) } const resultOutput = getToolResultContent(resultChunk, id) if (resultOutput) { toolCtx.output = resultOutput } else if (hookTool?.output) { toolCtx.output = hookTool.output } toolCtx.finishTime = hookTool?.hookFinishTime || chunkEmitTimes.get(chunks[Math.min(endIdx - 1, chunks.length - 1)]) return endIdx }, toolCtx) if (stepCtx && toolCtx.output) stepCtx.toolOutputs.push(toolCtx.output) nextIdx = Math.max(nextIdx, toolEndIdx) } return nextIdx } return chunks.length + 1 } /** * @param {Array<Record<string, unknown>>} chunks */ function processChunks (chunks, agentCtx) { let chunkIndex = 0 let stepIndex = 0 const sessionCtx = agentCtx.sessionCtx const getLifecycle = createStreamLookup(chunks) const { type, subtype, ...rest } = chunks[0] Object.assign(agentCtx, rest) if (sessionCtx) { if (sessionCtx.sessionId) agentCtx.session_id = sessionCtx.sessionId if (sessionCtx.cwd) agentCtx.cwd = sessionCtx.cwd if (sessionCtx.permissionMode) agentCtx.permissionMode = sessionCtx.permissionMode if (sessionCtx.lastAssistantMessage && !agentCtx.output) agentCtx.output = sessionCtx.lastAssistantMessage } while (chunkIndex < chunks.length) { if (chunks[chunkIndex].type === 'result') break const prevChunk = chunkIndex > 0 ? chunks[chunkIndex - 1] : chunks[chunkIndex] const stepCtx = { stepIndex, startTime: chunkEmitTimes.get(prevChunk), sessionId: agentCtx.session_id } const run = agentCtx.runInContext ?? (fn => fn()) chunkIndex = run(() => stepCh.traceSync(() => { const initialPrompt = sessionCtx?.prompt || agentCtx.arguments?.[0]?.prompt const nextIdx = processStep( chunks, chunkIndex, stepCtx.startTime, null, initialPrompt, stepCtx, sessionCtx, getLifecycle ) stepCtx.finishTime = chunkEmitTimes.get(chunks[Math.min(nextIdx - 1, chunks.length - 1)]) return nextIdx }, stepCtx)) stepIndex++ } } function finishStream (chunks, ctx, error) { if (ctx.streamResolved) return let processError if (chunks.length > 0) { const lastChunk = chunks.at(-1) if (lastChunk?.type === 'result') ctx.output = lastChunk.result try { processChunks(chunks, ctx) } catch (e) { processError = e } } const finalError = error || processError ctx.finishTime = Date.now() if (finalError) { ctx.error = finalError queryChannel.error.publish(ctx) } ctx.streamResolved = true queryChannel.asyncEnd.publish(ctx) if (processError && !error) throw processError } function wrapQueryAsyncIterator (asyncIterator, ctx) { const chunks = [] return function () { const iterator = asyncIterator.apply(this, arguments) iterator.next = shimmer.wrapCallback(iterator.next, next => function () { return next.apply(this, arguments).then(result => { const { done, value } = result if (!done && value) { const chunkEmitTime = Date.now() chunks.push(value) chunkEmitTimes.set(value, chunkEmitTime) } else { finishStream(chunks, ctx) } return result }).catch(error => { finishStream(chunks, ctx, error) throw error }) }) if (typeof iterator.return === 'function') { iterator.return = shimmer.wrapCallback(iterator.return, iteratorReturn => function () { return iteratorReturn.apply(this, arguments).then(result => { finishStream(chunks, ctx) return result }).catch(error => { finishStream(chunks, ctx, error) throw error }) }) } return iterator } } let querySubscribed = false for (const hook of getHooks('@anthropic-ai/claude-agent-sdk')) { hook.file = null addHook(hook, exports => { if (!querySubscribed) { querySubscribed = true queryChannel.subscribe({ start: onQueryStart, end (ctx) { if (!ctx.claudeAgentSdkTracing) return const { result } = ctx ctx.streamResolved = false shimmer.wrap(result, Symbol.asyncIterator, asyncIterator => wrapQueryAsyncIterator(asyncIterator, ctx)) }, }) } return exports }) }