| '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[chunks.length - 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 | ||
| }) | ||
| } |
| 'use strict' | ||
| module.exports = [ | ||
| { | ||
| module: { | ||
| name: '@anthropic-ai/claude-agent-sdk', | ||
| versionRange: '>=0.2.113', | ||
| filePath: 'sdk.mjs', | ||
| }, | ||
| functionQuery: { | ||
| functionName: 'query', | ||
| kind: 'Async', | ||
| isExportAlias: true, | ||
| }, | ||
| channelName: 'query', | ||
| }, | ||
| ] |
| 'use strict' | ||
| require('../../datadog-instrumentations/src/claude-agent-sdk') | ||
| const CompositePlugin = require('../../dd-trace/src/plugins/composite') | ||
| const ClaudeAgentSdkLLMObsPlugins = require('../../dd-trace/src/llmobs/plugins/claude-agent-sdk') | ||
| const ClaudeAgentSdkTracingPlugins = require('./tracing') | ||
| class ClaudeAgentSdkPlugin extends CompositePlugin { | ||
| static id = 'claude-agent-sdk' | ||
| static get plugins () { | ||
| const plugins = {} | ||
| // LLM Obs plugins must be registered before tracing plugins so that | ||
| // annotations are added to the span before it finishes. | ||
| // The tracing plugin uses `bindStart` vs the LLM Obs plugin's `start`, | ||
| // so the span is created in the tracing plugin before the LLM Obs one runs. | ||
| for (const Plugin of ClaudeAgentSdkLLMObsPlugins) { | ||
| plugins[Plugin.id] = Plugin | ||
| } | ||
| for (const Plugin of ClaudeAgentSdkTracingPlugins) { | ||
| plugins[Plugin.id] = Plugin | ||
| } | ||
| return plugins | ||
| } | ||
| } | ||
| module.exports = ClaudeAgentSdkPlugin |
| 'use strict' | ||
| const { storage } = require('../../datadog-core') | ||
| const TracingPlugin = require('../../dd-trace/src/plugins/tracing') | ||
| const { splitModel } = require('./util') | ||
| class QueryTracingPlugin extends TracingPlugin { | ||
| static id = 'claude_agent_sdk_query' | ||
| static operation = 'turn' | ||
| static system = 'claude-agent-sdk' | ||
| static prefix = 'tracing:orchestrion:@anthropic-ai/claude-agent-sdk:query' | ||
| bindStart (ctx) { | ||
| this.startSpan('claude_agent_sdk.query', { | ||
| meta: { 'resource.name': 'claude_agent_sdk.query' }, | ||
| startTime: ctx.startTime, | ||
| }, ctx) | ||
| ctx.runInContext = fn => storage('legacy').run(ctx.currentStore, fn) | ||
| return ctx.currentStore | ||
| } | ||
| asyncEnd (ctx) { | ||
| if (!ctx.streamResolved) return | ||
| ctx.currentStore?.span?.finish(ctx.finishTime) | ||
| } | ||
| } | ||
| class StepTracingPlugin extends TracingPlugin { | ||
| static id = 'claude_agent_sdk_step' | ||
| static operation = 'step' | ||
| static system = 'claude-agent-sdk' | ||
| static prefix = 'tracing:apm:claude-agent-sdk:step' | ||
| bindStart (ctx) { | ||
| this.startSpan(`step-${ctx.stepIndex}`, { | ||
| meta: { 'resource.name': 'claude_agent_sdk.step' }, | ||
| startTime: ctx.startTime, | ||
| }, ctx) | ||
| return ctx.currentStore | ||
| } | ||
| end (ctx) { | ||
| ctx.currentStore?.span?.finish(ctx.finishTime) | ||
| } | ||
| } | ||
| class ToolTracingPlugin extends TracingPlugin { | ||
| static id = 'claude_agent_sdk_tool' | ||
| static operation = 'tool' | ||
| static system = 'claude-agent-sdk' | ||
| static prefix = 'tracing:apm:claude-agent-sdk:tool' | ||
| bindStart (ctx) { | ||
| const toolName = ctx.name || 'claude_agent_sdk.tool' | ||
| this.startSpan(toolName, { | ||
| meta: { 'resource.name': 'claude_agent_sdk.tool' }, | ||
| startTime: ctx.startTime, | ||
| }, ctx) | ||
| return ctx.currentStore | ||
| } | ||
| end (ctx) { | ||
| const span = ctx.currentStore?.span | ||
| if (ctx.error) this.addError(ctx.error, span) | ||
| span?.finish(ctx.finishTime) | ||
| } | ||
| } | ||
| class LlmTracingPlugin extends TracingPlugin { | ||
| static id = 'claude_agent_sdk_llm' | ||
| static operation = 'llm' | ||
| static system = 'claude-agent-sdk' | ||
| static prefix = 'tracing:apm:claude-agent-sdk:llm' | ||
| bindStart (ctx) { | ||
| const { model } = ctx | ||
| const { modelName, modelProvider } = splitModel(model) | ||
| const name = modelName || 'claude_agent_sdk.llm' | ||
| this.startSpan(name, { | ||
| meta: { | ||
| 'resource.name': 'claude_agent_sdk.llm', | ||
| 'claude-agent-sdk.request.model_name': modelName, | ||
| 'claude-agent-sdk.request.model_provider': modelProvider, | ||
| }, | ||
| startTime: ctx.startTime, | ||
| }, ctx) | ||
| return ctx.currentStore | ||
| } | ||
| end (ctx) { | ||
| ctx.currentStore?.span?.finish(ctx.finishTime) | ||
| } | ||
| } | ||
| module.exports = [ | ||
| QueryTracingPlugin, | ||
| StepTracingPlugin, | ||
| ToolTracingPlugin, | ||
| LlmTracingPlugin, | ||
| ] |
| 'use strict' | ||
| /** | ||
| * Parse a model ID string into the model name and provider for claude agent sdk provided model IDs | ||
| * @param {string} model model id | ||
| * @returns {{ modelName: string, modelProvider: string }} | ||
| */ | ||
| function splitModel (model) { | ||
| if (!model) return { modelName: undefined, modelProvider: 'anthropic' } | ||
| const idx = model.indexOf('/') | ||
| if (idx === -1) return { modelName: model, modelProvider: 'anthropic' } | ||
| return { modelName: model.slice(idx + 1), modelProvider: model.slice(0, idx) } | ||
| } | ||
| module.exports = { splitModel } |
| 'use strict' | ||
| const MongodbCoreQueryPlugin = require('./query') | ||
| const MAX_RESOURCE_LENGTH = 10_000 | ||
| class MongodbCoreBulkWritePlugin extends MongodbCoreQueryPlugin { | ||
| // bulkWrite is higher-level than the wire commands `query` traces, so it has its own operation | ||
| // and thus its own `apm:mongodb:bulkwrite:*` channels. It reuses the query plugin's `id`, | ||
| // `component`, and collection-stripping `getPeerService`, and inherits `finish`, `error`, and the | ||
| // finish-time parent-store restore from the tracing/outbound base classes. | ||
| static operation = 'bulkwrite' | ||
| /** | ||
| * Open the parent span for a `Collection#bulkWrite`. The per-type wire commands nest as | ||
| * children and carry the statements, host, and DBM comment, so this span only records | ||
| * the namespace and resource. | ||
| * | ||
| * @param {{ ns: string }} ctx | ||
| */ | ||
| bindStart (ctx) { | ||
| const { ns } = ctx | ||
| const serviceResult = this.serviceName({ pluginConfig: this.config }) | ||
| this.startSpan(this.operationName(), { | ||
| service: serviceResult, | ||
| resource: truncate(`bulkWrite ${ns}`), | ||
| type: 'mongodb', | ||
| kind: 'client', | ||
| meta: { | ||
| 'db.name': ns, | ||
| }, | ||
| }, ctx) | ||
| return ctx.currentStore | ||
| } | ||
| } | ||
| function truncate (input) { | ||
| return input.length > MAX_RESOURCE_LENGTH ? input.slice(0, MAX_RESOURCE_LENGTH) : input | ||
| } | ||
| module.exports = MongodbCoreBulkWritePlugin |
| 'use strict' | ||
| const { isMap, isRegExp } = require('node:util').types | ||
| const DatabasePlugin = require('../../dd-trace/src/plugins/database') | ||
| class MongodbCoreQueryPlugin extends DatabasePlugin { | ||
| static id = 'mongodb-core' | ||
| static component = 'mongodb' | ||
| // avoid using db.name for peer.service since it includes the collection name | ||
| // should be removed if one day this will be fixed | ||
| /** | ||
| * @override | ||
| */ | ||
| static peerServicePrecursors = [] | ||
| /** | ||
| * @override | ||
| */ | ||
| configure (config) { | ||
| super.configure(config) | ||
| this.config.heartbeatEnabled = config.heartbeatEnabled ?? | ||
| this._tracerConfig.DD_TRACE_MONGODB_HEARTBEAT_ENABLED | ||
| this.config.obfuscateQuery = normaliseObfuscateQuery( | ||
| config.obfuscateQuery ?? this._tracerConfig.DD_TRACE_MONGODB_OBFUSCATE_QUERY | ||
| ) | ||
| } | ||
| bindStart (ctx) { | ||
| const { ns, ops, options = {}, name } = ctx | ||
| // heartbeat commands can be disabled if this.config.heartbeatEnabled is false | ||
| if (!this.config.heartbeatEnabled && isHeartbeat(ops, this.config)) { | ||
| return | ||
| } | ||
| const query = getQuery(ops, this.config.obfuscateQuery) | ||
| const resource = truncate(getResource(this, ns, query, name)) | ||
| const serviceResult = this.serviceName({ pluginConfig: this.config }) | ||
| const span = this.startSpan(this.operationName(), { | ||
| service: serviceResult, | ||
| resource, | ||
| type: 'mongodb', | ||
| kind: 'client', | ||
| meta: { | ||
| // this is not technically correct since it includes the collection but we changing will break customer stuff | ||
| 'db.name': ns, | ||
| 'mongodb.query': query, | ||
| 'out.host': options.host, | ||
| 'out.port': options.port, | ||
| }, | ||
| }, ctx) | ||
| const comment = this.injectDbmComment(span, ops.comment, serviceResult.name) | ||
| if (comment) { | ||
| ops.comment = comment | ||
| } | ||
| return ctx.currentStore | ||
| } | ||
| /** | ||
| * @override | ||
| */ | ||
| getPeerService (tags) { | ||
| let ns = tags['db.name'] | ||
| if (ns && tags['peer.service'] === undefined) { | ||
| const dotIndex = ns.indexOf('.') | ||
| if (dotIndex !== -1) { | ||
| ns = ns.slice(0, dotIndex) | ||
| } | ||
| // the mongo ns is either dbName either dbName.collection. So we keep the first part | ||
| tags['peer.service'] = ns | ||
| } | ||
| return super.getPeerService(tags) | ||
| } | ||
| injectDbmComment (span, comment, serviceName) { | ||
| const dbmTraceComment = this.createDbmComment(span, serviceName) | ||
| if (!dbmTraceComment) { | ||
| return comment | ||
| } | ||
| if (comment) { | ||
| // if the command already has a comment, append the dbm trace comment | ||
| if (typeof comment === 'string') { | ||
| comment += `,${dbmTraceComment}` | ||
| } else if (Array.isArray(comment)) { | ||
| comment.push(dbmTraceComment) | ||
| } // do nothing if the comment is not a string or an array | ||
| } else { | ||
| comment = dbmTraceComment | ||
| } | ||
| return comment | ||
| } | ||
| } | ||
| const MAX_DEPTH = 10 | ||
| const MAX_QUERY_LENGTH = 10_000 | ||
| function extractQuery (statements) { | ||
| if (statements.length === 1 && statements[0].q) return statements[0].q | ||
| const extractedQueries = [] | ||
| for (let i = 0; i < statements.length; i++) { | ||
| if (statements[i].q) { | ||
| extractedQueries.push(statements[i].q) | ||
| } | ||
| } | ||
| return extractedQueries | ||
| } | ||
| /** | ||
| * @param {Record<string, unknown> | unknown[] | undefined} cmd | ||
| * @param {'none' | 'types' | 'redact'} mode | ||
| */ | ||
| function getQuery (cmd, mode) { | ||
| if (!cmd || (typeof cmd !== 'object' && !Array.isArray(cmd))) return | ||
| if (Array.isArray(cmd)) return sanitiseAndStringify(extractQuery(cmd), mode) | ||
| if (cmd.query) return sanitiseAndStringify(cmd.query, mode) | ||
| if (cmd.filter) return sanitiseAndStringify(cmd.filter, mode) | ||
| if (cmd.pipeline) return sanitiseAndStringify(cmd.pipeline, mode) | ||
| if (cmd.deletes) return sanitiseAndStringify(extractQuery(cmd.deletes), mode) | ||
| if (cmd.updates) return sanitiseAndStringify(extractQuery(cmd.updates), mode) | ||
| } | ||
| function getResource (plugin, ns, query, operationName) { | ||
| let resource = `${operationName} ${ns}` | ||
| if (plugin.config.queryInResourceName && query) { | ||
| resource += ` ${query}` | ||
| } | ||
| return resource | ||
| } | ||
| function truncate (input) { | ||
| return input.length > MAX_QUERY_LENGTH ? input.slice(0, MAX_QUERY_LENGTH) : input | ||
| } | ||
| // Depth doubles as the cycle bound: a cycle pushes past MAX_DEPTH and bails, | ||
| // after which the slow path catches it via its ancestor stack. | ||
| /** @param {unknown} input */ | ||
| function canStringifyDirect (input) { | ||
| if (input === null || | ||
| typeof input !== 'object' || | ||
| ArrayBuffer.isView(input) || | ||
| input._bsontype !== undefined || | ||
| isRegExp(input) || | ||
| isMap(input) || | ||
| typeof input.toJSON === 'function') { | ||
| return false | ||
| } | ||
| return canStringifyDirectWalk(input, 1) | ||
| } | ||
| /** | ||
| * @param {Record<string, unknown> | unknown[]} value | ||
| * @param {number} depth | ||
| */ | ||
| function canStringifyDirectWalk (value, depth) { | ||
| if (depth > MAX_DEPTH) return false | ||
| const children = Array.isArray(value) ? value : Object.values(value) | ||
| for (const child of children) { | ||
| if (child === null || | ||
| typeof child === 'string' || | ||
| typeof child === 'number' || | ||
| typeof child === 'boolean') { | ||
| continue | ||
| } | ||
| if (typeof child !== 'object' || | ||
| ArrayBuffer.isView(child) || | ||
| child._bsontype !== undefined || | ||
| isRegExp(child) || | ||
| isMap(child) || | ||
| typeof child.toJSON === 'function') { | ||
| return false | ||
| } | ||
| if (!canStringifyDirectWalk(child, depth + 1)) return false | ||
| } | ||
| return true | ||
| } | ||
| /** | ||
| * @param {Record<string, unknown> | unknown[]} input | ||
| * @param {'none' | 'types' | 'redact'} mode | ||
| */ | ||
| function sanitiseAndStringify (input, mode) { | ||
| if (mode === 'none') { | ||
| if (canStringifyDirect(input)) return JSON.stringify(input) | ||
| return buildNone(input, []) | ||
| } | ||
| if (mode === 'redact') return buildRedact(input, []) | ||
| return buildTypes(input, []) | ||
| } | ||
| const REDACT_LEAF = '"?"' | ||
| /** | ||
| * @param {RegExp} value | ||
| * @returns {string} | ||
| */ | ||
| function stringifyRegExp (value) { | ||
| return `{"$regex":${JSON.stringify(value.source)},"$options":${JSON.stringify(value.flags)}}` | ||
| } | ||
| /** | ||
| * @param {Record<string, unknown> | unknown[]} value | ||
| * @param {object[]} ancestors | ||
| * @returns {string | undefined} | ||
| */ | ||
| function buildNone (value, ancestors) { | ||
| // ArrayBuffer views (Buffer, every TypedArray, DataView) and Binary BSON | ||
| // wrappers redact at the leaf; the walker neither recurses into the bytes | ||
| // nor invokes any custom conversion. | ||
| const bsontype = value._bsontype | ||
| if (ArrayBuffer.isView(value) || bsontype === 'Binary' || | ||
| ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { | ||
| return REDACT_LEAF | ||
| } | ||
| if (isRegExp(value)) return stringifyRegExp(value) | ||
| // Mirror JSON.stringify's contract: when `toJSON` is present, walk its | ||
| // result (wrappers like Timestamp / Decimal128 expand to a small object, | ||
| // ObjectId / Date flatten to a primitive). | ||
| if (typeof value.toJSON === 'function') { | ||
| const json = value.toJSON() | ||
| if (json === value) return REDACT_LEAF | ||
| // JSON.stringify keeps a null result as null (an invalid Date's toJSON | ||
| // returns null); only function / symbol / undefined results drop the key. | ||
| if (json === null) return 'null' | ||
| if (typeof json !== 'object') return classifyLeafForNone(json) | ||
| // A wrapper that exposes binary state through toJSON (Buffer-backed | ||
| // class with WeakMap state, etc.) returns a TypedArray here. Re-screen | ||
| // before the per-key walk would expand it element by element. | ||
| if (ArrayBuffer.isView(json) || json._bsontype === 'Binary') return REDACT_LEAF | ||
| value = json | ||
| } else if (bsontype !== undefined) { | ||
| return REDACT_LEAF | ||
| } | ||
| // The driver serializes a Map via its entries; mirror that as a document so | ||
| // the tag matches the wire shape. | ||
| if (isMap(value)) value = Object.fromEntries(value) | ||
| ancestors.push(value) | ||
| let result | ||
| if (Array.isArray(value)) { | ||
| result = '[' | ||
| let sep = '' | ||
| for (let i = 0; i < value.length; i++) { | ||
| // JSON.stringify renders unsupported leaves (function, symbol, undefined) as null in arrays. | ||
| result += sep + (classifyForNone(value[i], ancestors) ?? 'null') | ||
| sep = ',' | ||
| } | ||
| result += ']' | ||
| } else { | ||
| result = '{' | ||
| let sep = '' | ||
| for (const key of Object.keys(value)) { | ||
| const childResult = classifyForNone(value[key], ancestors) | ||
| if (childResult === undefined) continue | ||
| result += sep + JSON.stringify(key) + ':' + childResult | ||
| sep = ',' | ||
| } | ||
| result += '}' | ||
| } | ||
| ancestors.pop() | ||
| return result | ||
| } | ||
| /** | ||
| * @param {unknown} child | ||
| * @param {object[]} ancestors | ||
| * @returns {string | undefined} | ||
| */ | ||
| function classifyForNone (child, ancestors) { | ||
| if (typeof child !== 'object') return classifyLeafForNone(child) | ||
| if (child === null) return 'null' | ||
| return buildNone(child, ancestors) | ||
| } | ||
| /** | ||
| * @param {unknown} leaf | ||
| * @returns {string | undefined} | ||
| */ | ||
| function classifyLeafForNone (leaf) { | ||
| // Implicit `undefined` for function / symbol / undefined matches the | ||
| // contract callers rely on: JSON.stringify drops those property values | ||
| // inside objects and writes `null` in arrays. | ||
| switch (typeof leaf) { | ||
| case 'string': return JSON.stringify(leaf) | ||
| case 'number': return Number.isFinite(leaf) ? String(leaf) : 'null' | ||
| case 'boolean': return leaf ? 'true' : 'false' | ||
| case 'bigint': return `"${String(leaf)}"` | ||
| } | ||
| } | ||
| /** | ||
| * @param {Record<string, unknown> | unknown[]} value | ||
| * @param {object[]} ancestors | ||
| */ | ||
| function buildRedact (value, ancestors) { | ||
| const bsontype = value._bsontype | ||
| if (ArrayBuffer.isView(value) || bsontype === 'Binary' || isRegExp(value) || | ||
| ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { | ||
| return REDACT_LEAF | ||
| } | ||
| // Mirror JSON.stringify: when `toJSON` is present, walk its result (which | ||
| // wrappers like Timestamp / Decimal128 expand to `{$timestamp: "..."}` etc). | ||
| // A primitive, null, or self-reference collapses to the sentinel — master's | ||
| // `value === original` short-circuit. | ||
| if (typeof value.toJSON === 'function') { | ||
| const json = value.toJSON() | ||
| if (typeof json !== 'object' || json === null || json === value) return REDACT_LEAF | ||
| // Re-screen: toJSON can return a TypedArray or Binary BSON wrapper. | ||
| if (ArrayBuffer.isView(json) || json._bsontype === 'Binary') return REDACT_LEAF | ||
| value = json | ||
| } else if (bsontype !== undefined) { | ||
| return REDACT_LEAF | ||
| } | ||
| if (isMap(value)) value = Object.fromEntries(value) | ||
| ancestors.push(value) | ||
| let result | ||
| if (Array.isArray(value)) { | ||
| result = '[' | ||
| let sep = '' | ||
| for (let i = 0; i < value.length; i++) { | ||
| result += sep + classifyForRedact(value[i], ancestors) | ||
| sep = ',' | ||
| } | ||
| result += ']' | ||
| } else { | ||
| result = '{' | ||
| let sep = '' | ||
| for (const key of Object.keys(value)) { | ||
| result += sep + JSON.stringify(key) + ':' + classifyForRedact(value[key], ancestors) | ||
| sep = ',' | ||
| } | ||
| result += '}' | ||
| } | ||
| ancestors.pop() | ||
| return result | ||
| } | ||
| /** | ||
| * @param {unknown} child | ||
| * @param {object[]} ancestors | ||
| */ | ||
| function classifyForRedact (child, ancestors) { | ||
| if (typeof child !== 'object' || child === null) return REDACT_LEAF | ||
| return buildRedact(child, ancestors) | ||
| } | ||
| const TYPE_OBJECT = '"object"' | ||
| const TYPE_NULL = '"null"' | ||
| const TYPE_BY_TYPEOF = { | ||
| string: '"string"', | ||
| number: '"number"', | ||
| boolean: '"boolean"', | ||
| bigint: '"bigint"', | ||
| undefined: '"undefined"', | ||
| } | ||
| /** | ||
| * @param {Record<string, unknown> | unknown[]} value | ||
| * @param {object[]} ancestors | ||
| */ | ||
| function buildTypes (value, ancestors) { | ||
| const bsontype = value._bsontype | ||
| if (ArrayBuffer.isView(value) || bsontype === 'Binary' || isRegExp(value) || | ||
| ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { | ||
| return TYPE_OBJECT | ||
| } | ||
| if (typeof value.toJSON === 'function') { | ||
| const json = value.toJSON() | ||
| if (typeof json !== 'object' || | ||
| json === null || | ||
| json === value || | ||
| ArrayBuffer.isView(json) || | ||
| json._bsontype === 'Binary') { | ||
| return TYPE_OBJECT | ||
| } | ||
| value = json | ||
| } else if (bsontype !== undefined) { | ||
| return TYPE_OBJECT | ||
| } | ||
| if (isMap(value)) value = Object.fromEntries(value) | ||
| ancestors.push(value) | ||
| let result | ||
| if (Array.isArray(value)) { | ||
| result = '[' | ||
| let sep = '' | ||
| for (let i = 0; i < value.length; i++) { | ||
| // JSON.stringify renders unsupported leaves (function, symbol) as null in arrays. | ||
| result += sep + (classifyForTypes(value[i], ancestors) ?? 'null') | ||
| sep = ',' | ||
| } | ||
| result += ']' | ||
| } else { | ||
| result = '{' | ||
| let sep = '' | ||
| for (const key of Object.keys(value)) { | ||
| const childResult = classifyForTypes(value[key], ancestors) | ||
| if (childResult === undefined) continue | ||
| result += sep + JSON.stringify(key) + ':' + childResult | ||
| sep = ',' | ||
| } | ||
| result += '}' | ||
| } | ||
| ancestors.pop() | ||
| return result | ||
| } | ||
| /** | ||
| * @param {unknown} child | ||
| * @param {object[]} ancestors | ||
| */ | ||
| function classifyForTypes (child, ancestors) { | ||
| if (typeof child !== 'object') return TYPE_BY_TYPEOF[typeof child] | ||
| if (child === null) return TYPE_NULL | ||
| return buildTypes(child, ancestors) | ||
| } | ||
| /** @param {unknown} value */ | ||
| function normaliseObfuscateQuery (value) { | ||
| if (value === 'types' || value === 'redact') return value | ||
| return 'none' | ||
| } | ||
| function isHeartbeat (ops, config) { | ||
| // Check if it's a heartbeat command https://github.com/mongodb/specifications/blob/master/source/mongodb-handshake/handshake.md | ||
| return ( | ||
| ops && | ||
| typeof ops === 'object' && | ||
| (ops.hello === 1 || ops.helloOk === true || ops.ismaster === 1 || ops.isMaster === 1) | ||
| ) | ||
| } | ||
| module.exports = MongodbCoreQueryPlugin |
| 'use strict' | ||
| const openai = require('./openai') | ||
| const vercelAi = require('./vercel-ai') | ||
| let isEnabled = false | ||
| /** | ||
| * Enables all AI Guard provider integrations. | ||
| * | ||
| * @param {object} aiguard | ||
| * @param {boolean} block | ||
| */ | ||
| function enable (aiguard, block) { | ||
| if (isEnabled) return | ||
| openai.enable(aiguard, block) | ||
| vercelAi.enable(aiguard, block) | ||
| isEnabled = true | ||
| } | ||
| function disable () { | ||
| vercelAi.disable() | ||
| openai.disable() | ||
| isEnabled = false | ||
| } | ||
| module.exports = { | ||
| enable, | ||
| disable, | ||
| openai, | ||
| vercelAi, | ||
| } |
| 'use strict' | ||
| const { readFileSync } = require('node:fs') | ||
| const { extname } = require('node:path') | ||
| const getConfig = require('../../config') | ||
| const request = require('../../exporters/common/request') | ||
| const log = require('../../log') | ||
| const UPLOAD_TIMEOUT_MS = 30_000 | ||
| const TEST_SCREENSHOT_ENDPOINT_PREFIX = '/api/v2/ci/test-runs/' | ||
| const TEST_SCREENSHOT_ENDPOINT_SUFFIX = '/media' | ||
| const UINT64_MAX = 18_446_744_073_709_551_615n | ||
| function getContentType (filePath) { | ||
| const extension = extname(filePath).toLowerCase() | ||
| if (extension === '.gif') { | ||
| return 'image/gif' | ||
| } | ||
| if (extension === '.jpg' || extension === '.jpeg') { | ||
| return 'image/jpeg' | ||
| } | ||
| if (extension === '.webp') { | ||
| return 'image/webp' | ||
| } | ||
| return 'image/png' | ||
| } | ||
| function isValidTraceId (traceId) { | ||
| if (!/^[1-9]\d*$/.test(traceId)) { | ||
| return false | ||
| } | ||
| return BigInt(traceId) <= UINT64_MAX | ||
| } | ||
| /** | ||
| * Renders the idempotency key (`${traceId}:${basename(filePath)}`) into a value safe to carry in | ||
| * the upload's query string. The Agent's evp_proxy validates the forwarded query against a | ||
| * restrictive charset and rejects a raw Cypress filename (spaces, parens, non-ASCII), so the | ||
| * filename part is hex-encoded to [0-9a-f]; the decimal trace id and ':' separator are already in | ||
| * the allowed set and stay readable. Deterministic, so a retried upload reproduces the same key | ||
| * and the backend's UUIDv5 overwrite-on-retry holds. | ||
| * | ||
| * @param {string} idempotencyKey - The raw idempotency key (`<traceId>:<filename>`) | ||
| * @returns {string} A query-safe, deterministic representation of the key | ||
| */ | ||
| function toIdempotencyQueryValue (idempotencyKey) { | ||
| const separatorIndex = idempotencyKey.indexOf(':') | ||
| if (separatorIndex === -1) { | ||
| return Buffer.from(idempotencyKey, 'utf8').toString('hex') | ||
| } | ||
| const traceIdPart = idempotencyKey.slice(0, separatorIndex) | ||
| const filenamePart = idempotencyKey.slice(separatorIndex + 1) | ||
| return `${traceIdPart}:${Buffer.from(filenamePart, 'utf8').toString('hex')}` | ||
| } | ||
| /** | ||
| * Uploads a single test screenshot to the Test Optimization media intake. | ||
| * The trace id is included in the request path and the body is the raw image bytes. | ||
| * | ||
| * The media service requires two values from the tracer, sent as query params so they | ||
| * survive the Agent's evp_proxy (which forwards only an allow-listed header set and would | ||
| * otherwise strip the metadata, leaving the backend with an empty idempotency key): | ||
| * - `idempotencyKey`: stable per artifact and reused on retry, so a retried | ||
| * upload overwrites the same stored object instead of creating a duplicate. | ||
| * - `capturedAtMs`: the capture time in epoch milliseconds, stamped once at | ||
| * capture and resent unchanged on retry (it is part of the stored object key). | ||
| * | ||
| * @param {object} options - Upload options | ||
| * @param {string} options.filePath - Path to the screenshot file | ||
| * @param {string} options.traceId - Test trace id used as the screenshot key | ||
| * @param {string} options.idempotencyKey - Stable per-artifact key, reused on retry | ||
| * @param {number} options.capturedAtMs - Capture time in epoch milliseconds | ||
| * @param {URL} options.url - The base URL for the screenshot upload | ||
| * @param {boolean} [options.isEvpProxy] - Whether to upload through the Agent's evp_proxy | ||
| * @param {string} [options.evpProxyPrefix] - The evp_proxy path prefix (e.g. '/evp_proxy/v4') | ||
| * @param {Function} callback - Callback function (err) | ||
| */ | ||
| function uploadTestScreenshot ( | ||
| { filePath, traceId, idempotencyKey, capturedAtMs, url, isEvpProxy, evpProxyPrefix }, | ||
| callback | ||
| ) { | ||
| const { DD_API_KEY } = getConfig() | ||
| if (!isValidTraceId(traceId)) { | ||
| return callback(new Error('A non-zero decimal uint64 trace_id is required for test screenshot upload')) | ||
| } | ||
| if (!DD_API_KEY && !isEvpProxy) { | ||
| return callback(new Error('DD_API_KEY is required for test screenshot upload')) | ||
| } | ||
| if (!idempotencyKey) { | ||
| return callback(new Error('An idempotency key is required for test screenshot upload')) | ||
| } | ||
| if (!Number.isInteger(capturedAtMs) || capturedAtMs <= 0) { | ||
| return callback(new Error('A positive captured-at timestamp (epoch ms) is required for test screenshot upload')) | ||
| } | ||
| let screenshotContent | ||
| try { | ||
| screenshotContent = readFileSync(filePath) | ||
| } catch (err) { | ||
| return callback(new Error(`Failed to read screenshot at ${filePath}: ${err.message}`)) | ||
| } | ||
| if (screenshotContent.length === 0) { | ||
| return callback(new Error(`Screenshot at ${filePath} is empty`)) | ||
| } | ||
| // Metadata rides the query string, not X-Dd-* headers: the Agent's evp_proxy strips | ||
| // non-allow-listed headers, so header-borne metadata reached the backend empty. The key is | ||
| // rendered proxy-safe (see toIdempotencyQueryValue) because evp_proxy also validates the | ||
| // forwarded query against a restrictive charset. capturedAtMs is a plain integer and part of the | ||
| // stored object key, so it (and the key) must stay stable across retries. | ||
| const query = new URLSearchParams({ | ||
| idempotency_key: toIdempotencyQueryValue(idempotencyKey), | ||
| captured_at_ms: String(capturedAtMs), | ||
| }).toString() | ||
| const basePath = `${TEST_SCREENSHOT_ENDPOINT_PREFIX}${traceId}${TEST_SCREENSHOT_ENDPOINT_SUFFIX}` | ||
| const contentType = getContentType(filePath) | ||
| const options = { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': contentType, | ||
| }, | ||
| path: `${basePath}?${query}`, | ||
| timeout: UPLOAD_TIMEOUT_MS, | ||
| url, | ||
| } | ||
| if (isEvpProxy) { | ||
| // Agent mode: prefix the evp_proxy path, tell the proxy which subdomain to forward to, and | ||
| // drop the API key — the Agent injects it. The query params survive the proxy. | ||
| options.path = `${evpProxyPrefix}${basePath}?${query}` | ||
| options.headers['X-Datadog-EVP-Subdomain'] = 'api' | ||
| } else { | ||
| options.headers['DD-API-KEY'] = DD_API_KEY | ||
| } | ||
| log.debug('Uploading test screenshot %s to %s', filePath, new URL(options.path, url).href) | ||
| request(screenshotContent, options, (err, res, statusCode) => { | ||
| if (err) { | ||
| log.error('Error uploading test screenshot: %s', err.message) | ||
| return callback(err) | ||
| } | ||
| if (statusCode === undefined) { | ||
| const uploadError = new Error('Test screenshot upload request was dropped before it was sent') | ||
| log.error('Error uploading test screenshot: %s', uploadError.message) | ||
| return callback(uploadError) | ||
| } | ||
| log.debug('Test screenshot uploaded successfully (status: %d)', statusCode) | ||
| callback(null) | ||
| }) | ||
| } | ||
| module.exports = { TEST_SCREENSHOT_ENDPOINT_PREFIX, TEST_SCREENSHOT_ENDPOINT_SUFFIX, uploadTestScreenshot } |
| 'use strict' | ||
| const { channel } = require('dc-polyfill') | ||
| const BaseLLMObsPlugin = require('../base') | ||
| const { getModelProvider } = require('../../../../../datadog-plugin-ai/src/utils') | ||
| const toolCreationCh = channel('tracing:orchestrion:ai:tool:start') | ||
| const setAttributesCh = channel('dd-trace:vercel-ai:span:setAttributes') | ||
| const { MODEL_NAME, MODEL_PROVIDER, NAME } = require('../../constants/tags') | ||
| const { | ||
| getSpanTags, | ||
| getOperation, | ||
| getUsage, | ||
| getJsonStringValue, | ||
| getModelMetadata, | ||
| getGenerationMetadata, | ||
| getToolNameFromTags, | ||
| getToolCallResultContent, | ||
| getLlmObsSpanName, | ||
| getTelemetryMetadata, | ||
| } = require('./util') | ||
| /** | ||
| * @typedef {Record<string, unknown> & { description?: string, id?: string }} AvailableToolArgs | ||
| */ | ||
| /** | ||
| * @typedef {string | number | boolean | null | undefined | string[] | number[] | boolean[]} TagValue | ||
| * @typedef {Record<string, TagValue>} SpanTags | ||
| * | ||
| * @typedef {{ name?: string, description?: string }} ToolForModel | ||
| * | ||
| * @typedef {{ type: 'text' | 'reasoning' | 'redacted-reasoning', text?: string, data?: string }} TextPart | ||
| * @typedef {{ type: 'tool-call', toolName: string, toolCallId: string, args?: unknown, input?: unknown }} ToolCallPart | ||
| * @typedef {( | ||
| * { type: 'tool-result', toolCallId: string, output?: { type: string, value?: unknown }, result?: unknown } & | ||
| * Record<string, unknown> | ||
| * )} ToolResultPart | ||
| * | ||
| * @typedef {{ | ||
| * role: 'system', | ||
| * content: string | ||
| * } | { | ||
| * role: 'user', | ||
| * content: TextPart[] | ||
| * } | { | ||
| * role: 'assistant', | ||
| * content: Array<TextPart | ToolCallPart> | ||
| * } | { | ||
| * role: 'tool', | ||
| * content: ToolResultPart[] | ||
| * }} AiSdkMessage | ||
| */ | ||
| const SPAN_NAME_TO_KIND_MAPPING = { | ||
| // embeddings | ||
| embed: 'workflow', | ||
| embedMany: 'workflow', | ||
| doEmbed: 'embedding', | ||
| // object generation | ||
| generateObject: 'workflow', | ||
| streamObject: 'workflow', | ||
| // text generation | ||
| generateText: 'workflow', | ||
| streamText: 'workflow', | ||
| // llm operations | ||
| doGenerate: 'llm', | ||
| doStream: 'llm', | ||
| // tools | ||
| toolCall: 'tool', | ||
| } | ||
| class DdTelemetryPlugin extends BaseLLMObsPlugin { | ||
| static id = 'ai_llmobs_dd_telemetry' | ||
| static integration = 'ai' | ||
| static prefix = 'tracing:dd-trace:vercel-ai' | ||
| /** | ||
| * The available tools within the runtime scope of this integration. | ||
| * This essentially acts as a global registry for all tools made through the Vercel AI SDK. | ||
| * @type {Set<AvailableToolArgs>} | ||
| */ | ||
| #availableTools | ||
| /** | ||
| * A mapping of tool call IDs to tool names. | ||
| * This is used to map the tool call ID to the tool name for the output message. | ||
| * @type {Record<string, string>} | ||
| */ | ||
| #toolCallIdsToName | ||
| constructor (...args) { | ||
| super(...args) | ||
| this.#toolCallIdsToName = {} | ||
| this.#availableTools = new Set() | ||
| toolCreationCh.subscribe(ctx => { | ||
| const toolArgs = ctx.arguments | ||
| const tool = toolArgs[0] ?? {} | ||
| this.#availableTools.add(tool) | ||
| }) | ||
| setAttributesCh.subscribe(({ ctx, attributes }) => { | ||
| Object.assign(ctx.attributes, attributes) | ||
| }) | ||
| } | ||
| /** | ||
| * Does a best-effort attempt to find the right tool name for the given tool description. | ||
| * This is because the Vercel AI SDK does not tag tools by name properly, but | ||
| * rather by the index they were passed in. Tool names appear nowhere in the span tags. | ||
| * | ||
| * We use the tool description as the next best identifier for a tool. | ||
| * | ||
| * @param {string} toolName | ||
| * @param {string | undefined} toolDescription | ||
| * @returns {string | undefined} | ||
| */ | ||
| findToolName (toolName, toolDescription) { | ||
| if (Number.isNaN(Number.parseInt(toolName))) return toolName | ||
| for (const availableTool of this.#availableTools) { | ||
| const description = availableTool.description | ||
| if (description === toolDescription && availableTool.id) { | ||
| return availableTool.id | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * @override | ||
| */ | ||
| getLLMObsSpanRegisterOptions (ctx) { | ||
| const span = ctx.currentStore?.span | ||
| const operation = getOperation(span) | ||
| const kind = SPAN_NAME_TO_KIND_MAPPING[operation] | ||
| if (!kind) return | ||
| return { kind, name: getLlmObsSpanName(operation, ctx.attributes['ai.telemetry.functionId']) } | ||
| } | ||
| /** | ||
| * @override | ||
| */ | ||
| setLLMObsTags (ctx) { | ||
| const span = ctx.currentStore?.span | ||
| if (!span) return | ||
| const operation = getOperation(span) | ||
| const kind = SPAN_NAME_TO_KIND_MAPPING[operation] | ||
| if (!kind) return | ||
| const tags = getSpanTags(ctx) | ||
| if (['embedding', 'llm'].includes(kind)) { | ||
| this._tagger._setTag(span, MODEL_NAME, tags['ai.model.id']) | ||
| this._tagger._setTag(span, MODEL_PROVIDER, getModelProvider(tags)) | ||
| } | ||
| switch (operation) { | ||
| case 'embed': | ||
| case 'embedMany': | ||
| this.setEmbeddingWorkflowTags(span, tags) | ||
| break | ||
| case 'doEmbed': | ||
| this.setEmbeddingTags(span, tags) | ||
| break | ||
| case 'generateObject': | ||
| case 'streamObject': | ||
| this.setObjectGenerationTags(span, tags) | ||
| break | ||
| case 'generateText': | ||
| case 'streamText': | ||
| this.setTextGenerationTags(span, tags) | ||
| break | ||
| case 'doGenerate': | ||
| case 'doStream': | ||
| this.setLLMOperationTags(span, tags) | ||
| break | ||
| case 'toolCall': | ||
| this.setToolTags(span, tags) | ||
| break | ||
| default: | ||
| break | ||
| } | ||
| } | ||
| setEmbeddingWorkflowTags (span, tags) { | ||
| const inputs = tags['ai.value'] ?? tags['ai.values'] | ||
| const parsedInputs = Array.isArray(inputs) | ||
| ? inputs.map(input => getJsonStringValue(input, '')) | ||
| : getJsonStringValue(inputs, '') | ||
| const embeddingsOutput = tags['ai.embedding'] ?? tags['ai.embeddings'] | ||
| const isSingleEmbedding = !Array.isArray(embeddingsOutput) | ||
| const numberOfEmbeddings = isSingleEmbedding ? 1 : embeddingsOutput.length | ||
| const embeddingsLength = getJsonStringValue(isSingleEmbedding ? embeddingsOutput : embeddingsOutput?.[0], []).length | ||
| const output = `[${numberOfEmbeddings} embedding(s) returned with size ${embeddingsLength}]` | ||
| this._tagger.tagTextIO(span, parsedInputs, output) | ||
| const metadata = getGenerationMetadata(tags) | ||
| this._tagger.tagMetadata(span, metadata) | ||
| } | ||
| setEmbeddingTags (span, tags) { | ||
| const inputs = tags['ai.values'] | ||
| if (!Array.isArray(inputs)) return | ||
| const parsedInputs = inputs.map(input => getJsonStringValue(input, '')) | ||
| const embeddingsOutput = tags['ai.embeddings'] | ||
| const numberOfEmbeddings = embeddingsOutput?.length | ||
| const embeddingsLength = getJsonStringValue(embeddingsOutput?.[0], []).length | ||
| const output = `[${numberOfEmbeddings} embedding(s) returned with size ${embeddingsLength}]` | ||
| this._tagger.tagEmbeddingIO(span, parsedInputs, output) | ||
| const metadata = getTelemetryMetadata(tags) | ||
| this._tagger.tagMetadata(span, metadata) | ||
| const usage = tags['ai.usage.tokens'] | ||
| this._tagger.tagMetrics(span, { | ||
| inputTokens: usage, | ||
| totalTokens: usage, | ||
| }) | ||
| } | ||
| setObjectGenerationTags (span, tags) { | ||
| const promptInfo = getJsonStringValue(tags['ai.prompt'], {}) | ||
| const lastUserPrompt = | ||
| promptInfo.prompt ?? | ||
| promptInfo.messages.reverse().find(message => message.role === 'user')?.content | ||
| const prompt = Array.isArray(lastUserPrompt) ? lastUserPrompt.map(part => part.text ?? '').join('') : lastUserPrompt | ||
| const output = tags['ai.response.object'] | ||
| this._tagger.tagTextIO(span, prompt, output) | ||
| const metadata = getGenerationMetadata(tags) | ||
| metadata.schema = getJsonStringValue(tags['ai.schema'], {}) | ||
| this._tagger.tagMetadata(span, metadata) | ||
| } | ||
| setTextGenerationTags (span, tags) { | ||
| const promptInfo = getJsonStringValue(tags['ai.prompt'], {}) | ||
| const lastUserPrompt = | ||
| promptInfo.prompt ?? | ||
| promptInfo.messages.reverse().find(message => message.role === 'user')?.content | ||
| const prompt = Array.isArray(lastUserPrompt) ? lastUserPrompt.map(part => part.text ?? '').join('') : lastUserPrompt | ||
| const output = tags['ai.response.text'] | ||
| this._tagger.tagTextIO(span, prompt, output) | ||
| const metadata = getGenerationMetadata(tags) | ||
| this._tagger.tagMetadata(span, metadata) | ||
| } | ||
| /** | ||
| * @param {import('../../../opentracing/span')} span | ||
| * @param {SpanTags} tags | ||
| */ | ||
| setLLMOperationTags (span, tags) { | ||
| const toolsForModel = tags['ai.prompt.tools']?.map(getJsonStringValue) | ||
| const inputMessages = getJsonStringValue(tags['ai.prompt.messages'], []) | ||
| const parsedInputMessages = [] | ||
| for (const message of inputMessages) { | ||
| const formattedMessages = this.formatMessage(message, toolsForModel) | ||
| parsedInputMessages.push(...formattedMessages) | ||
| } | ||
| const outputMessage = this.formatOutputMessage(tags, toolsForModel) | ||
| this._tagger.tagLLMIO(span, parsedInputMessages, outputMessage) | ||
| const metadata = getModelMetadata(tags) | ||
| this._tagger.tagMetadata(span, metadata) | ||
| const usage = getUsage(tags) | ||
| this._tagger.tagMetrics(span, usage) | ||
| } | ||
| setToolTags (span, tags) { | ||
| const toolCallId = tags['ai.toolCall.id'] | ||
| const name = getToolNameFromTags(tags) ?? this.#toolCallIdsToName[toolCallId] | ||
| if (name) this._tagger._setTag(span, NAME, name) | ||
| const input = tags['ai.toolCall.args'] | ||
| const output = tags['ai.toolCall.result'] | ||
| this._tagger.tagTextIO(span, input, output) | ||
| } | ||
| formatOutputMessage (tags, toolsForModel) { | ||
| const outputMessageText = tags['ai.response.text'] ?? tags['ai.response.object'] | ||
| const outputMessageToolCalls = getJsonStringValue(tags['ai.response.toolCalls'], []) | ||
| const formattedToolCalls = [] | ||
| for (const toolCall of outputMessageToolCalls) { | ||
| const toolArgs = toolCall.args ?? toolCall.input | ||
| const toolCallArgs = typeof toolArgs === 'string' ? getJsonStringValue(toolArgs, {}) : toolArgs | ||
| const toolDescription = toolsForModel?.find(tool => toolCall.toolName === tool.name)?.description | ||
| const name = this.findToolName(toolCall.toolName, toolDescription) | ||
| this.#toolCallIdsToName[toolCall.toolCallId] = name | ||
| formattedToolCalls.push({ | ||
| arguments: toolCallArgs, | ||
| name, | ||
| toolId: toolCall.toolCallId, | ||
| type: toolCall.toolCallType ?? 'function', | ||
| }) | ||
| } | ||
| return { | ||
| role: 'assistant', | ||
| content: outputMessageText, | ||
| toolCalls: formattedToolCalls, | ||
| } | ||
| } | ||
| /** | ||
| * Returns a list of formatted messages from a message object. | ||
| * Most of these will just be one entry, but in the case of a "tool" role, | ||
| * it is possible to have multiple tool call results in a single message that we | ||
| * need to split into multiple messages. | ||
| * | ||
| * @param {AiSdkMessage} message | ||
| * @param {ToolForModel[] | null | undefined} toolsForModel | ||
| * @returns {Array<{role: string, content: string, toolId?: string, | ||
| * toolCalls?: Array<{arguments: string, name: string, toolId: string, type: string}>}>} | ||
| */ | ||
| formatMessage (message, toolsForModel) { | ||
| const { role, content } = message | ||
| if (role === 'system') { | ||
| return [{ role, content }] | ||
| } else if (role === 'user') { | ||
| let finalContent = '' | ||
| for (const part of content) { | ||
| const { type } = part | ||
| if (type === 'text') { | ||
| finalContent += part.text | ||
| } | ||
| } | ||
| return [{ role, content: finalContent }] | ||
| } else if (role === 'assistant') { | ||
| const toolCalls = [] | ||
| let finalContent = '' | ||
| for (const part of content) { | ||
| const { type } = part | ||
| // TODO(sabrenner): do we want to include reasoning? | ||
| if (['text', 'reasoning', 'redacted-reasoning'].includes(type)) { | ||
| finalContent += part.text ?? part.data | ||
| } else if (type === 'tool-call') { | ||
| const toolDescription = toolsForModel?.find(tool => part.toolName === tool.name)?.description | ||
| const name = this.findToolName(part.toolName, toolDescription) | ||
| toolCalls.push({ | ||
| arguments: part.args ?? part.input, | ||
| name, | ||
| toolId: part.toolCallId, | ||
| type: 'function', | ||
| }) | ||
| } | ||
| } | ||
| const finalMessage = { | ||
| role, | ||
| content: finalContent, | ||
| } | ||
| if (toolCalls.length) { | ||
| finalMessage.toolCalls = toolCalls.length ? toolCalls : undefined | ||
| } | ||
| return [finalMessage] | ||
| } else if (role === 'tool') { | ||
| const finalMessages = [] | ||
| for (const part of content) { | ||
| if (part.type === 'tool-result') { | ||
| const safeResult = getToolCallResultContent(part) | ||
| finalMessages.push({ | ||
| role, | ||
| content: safeResult, | ||
| toolId: part.toolCallId, | ||
| }) | ||
| } | ||
| } | ||
| return finalMessages | ||
| } | ||
| return [] | ||
| } | ||
| } | ||
| module.exports = DdTelemetryPlugin |
| 'use strict' | ||
| const { parseModelProvider } = require('../../../../../datadog-plugin-ai/src/utils') | ||
| const BaseLLMObsPlugin = require('../base') | ||
| const { | ||
| getLlmObsSpanName, | ||
| getGenerationMetadataFromEvent, | ||
| getJsonStringValue, | ||
| getToolCallResultContent, | ||
| } = require('./util') | ||
| // TODO: add in embedMany once it has tracingChannel support | ||
| const SPAN_NAME_TO_KIND_MAPPING = { | ||
| // embeddings | ||
| embed: 'embedding', | ||
| // text generation | ||
| generateText: 'workflow', | ||
| streamText: 'workflow', | ||
| // llm operations | ||
| languageModelCall: 'llm', | ||
| // steps | ||
| step: 'step', // TODO: support step spans for manual instrumentation as well | ||
| // tools | ||
| executeTool: 'tool', | ||
| } | ||
| function nameFromOperation (operation, event) { | ||
| if (operation === 'executeTool') { | ||
| return event.toolCall?.toolName | ||
| } | ||
| return operation | ||
| } | ||
| function formatLanguageModelInputMessages (instructions, messages) { | ||
| if (!Array.isArray(messages)) return | ||
| const inputMessages = [] | ||
| if (instructions) { | ||
| const systemPrompt = typeof instructions === 'string' | ||
| ? instructions | ||
| : Array.isArray(instructions) | ||
| ? instructions.map(instruction => instruction.content).join('') | ||
| : instructions.content | ||
| inputMessages.push({ role: 'system', content: systemPrompt }) | ||
| } | ||
| for (const message of messages) { | ||
| const { role, content } = message | ||
| if (role === 'system') { | ||
| inputMessages.push({ role, content }) | ||
| } else if (role === 'user') { | ||
| const userMessageContent = | ||
| typeof content === 'string' | ||
| ? content | ||
| : content | ||
| .filter(part => part.type === 'text') | ||
| .map(part => part.text) | ||
| .join('') | ||
| inputMessages.push({ role, content: userMessageContent }) | ||
| } else if (role === 'assistant') { | ||
| if (typeof content === 'string') { | ||
| inputMessages.push({ role, content }) | ||
| } else { | ||
| // re-use existing output message formatting | ||
| inputMessages.push(...formatLanguageModelOutputMessages(content)) | ||
| } | ||
| } else if (role === 'tool') { | ||
| for (const part of content) { | ||
| if (part.type === 'tool-result') { // TODO: support tool approvals | ||
| const safeResult = getToolCallResultContent(part) | ||
| inputMessages.push({ | ||
| role, | ||
| content: safeResult, | ||
| toolId: part.toolCallId, | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return inputMessages | ||
| } | ||
| function formatLanguageModelOutputMessages (content) { | ||
| if (!Array.isArray(content)) return | ||
| const outputMessages = [] | ||
| const toolCalls = [] | ||
| let textContent = '' | ||
| let reasoningContent = '' | ||
| for (const part of content) { | ||
| const { type } = part | ||
| if (type === 'text') { | ||
| textContent += part.text | ||
| } else if (type === 'reasoning') { | ||
| reasoningContent += part.text | ||
| } else if (type === 'tool-call') { | ||
| const toolCallArguments = typeof part.input === 'string' | ||
| ? getJsonStringValue(part.input, {}) | ||
| : part.input | ||
| toolCalls.push({ | ||
| arguments: toolCallArguments, | ||
| name: part.toolName, | ||
| type: 'function', | ||
| toolId: part.toolCallId, | ||
| }) | ||
| } | ||
| } | ||
| if (reasoningContent) { | ||
| outputMessages.push({ role: 'reasoning', content: reasoningContent }) | ||
| } | ||
| const finalTextMessage = { role: 'assistant' } | ||
| if (textContent) { | ||
| finalTextMessage.content = textContent | ||
| } | ||
| if (toolCalls.length) { | ||
| finalTextMessage.toolCalls = toolCalls | ||
| } | ||
| outputMessages.push(finalTextMessage) | ||
| return outputMessages | ||
| } | ||
| class VercelAiTelemetryPlugin extends BaseLLMObsPlugin { | ||
| static id = 'ai_llmobs_vercel_telemetry' | ||
| static integration = 'ai' | ||
| static prefix = 'tracing:ai:telemetry' | ||
| /** @type {Map<string, Array<Record<string, unknown>>>} */ | ||
| #lastOutputContentByCallId = new Map() | ||
| constructor () { | ||
| super(...arguments) | ||
| this.addSub('dd-trace:vercel-ai:chunk', ({ ctx, chunk, done }) => { | ||
| ctx.chunks ??= [] | ||
| const chunks = ctx.chunks | ||
| if (chunk) chunks.push(chunk) | ||
| ctx.streamConsumed = done | ||
| if (!done) return | ||
| ctx.result ??= {} | ||
| const contentById = {} | ||
| for (const chunk of chunks) { | ||
| if (chunk.type === 'finish') { | ||
| ctx.result.usage = chunk.usage | ||
| } else if (chunk.type === 'reasoning-start') { | ||
| contentById[chunk.id] = { type: 'reasoning', text: '' } | ||
| } else if (chunk.type === 'text-start') { | ||
| contentById[chunk.id] = { type: 'text', text: '' } | ||
| } else if (chunk.type === 'reasoning-delta' || chunk.type === 'text-delta') { | ||
| if (!contentById[chunk.id]) { | ||
| // special-case handling when the start event did not come through as a chunk | ||
| contentById[chunk.id] = { | ||
| type: chunk.type === 'reasoning-delta' ? 'reasoning' : 'text', | ||
| text: '', | ||
| } | ||
| } | ||
| contentById[chunk.id].text += chunk.delta | ||
| } else if (chunk.type === 'tool-call') { | ||
| contentById[chunk.toolCallId] = chunk | ||
| } | ||
| } | ||
| const content = Object.values(contentById) | ||
| ctx.result.content = content | ||
| this.#lastOutputContentByCallId.set(ctx.event.callId, content) | ||
| }) | ||
| } | ||
| /** | ||
| * @override | ||
| */ | ||
| asyncEnd (ctx) { | ||
| // check if isStreamed and stream resolved | ||
| // this event will fire multiple times for the same channel | ||
| if (ctx.isStream && ctx.result?.stream && !ctx.streamConsumed) return | ||
| super.asyncEnd(ctx) | ||
| } | ||
| /** | ||
| * @override | ||
| */ | ||
| getLLMObsSpanRegisterOptions (ctx) { | ||
| const span = ctx.currentStore?.span | ||
| if (!span) return | ||
| const { type: operation, event } = ctx | ||
| const kind = SPAN_NAME_TO_KIND_MAPPING[operation] | ||
| if (!kind) return | ||
| const normalizedName = nameFromOperation(operation, event) || operation | ||
| const options = { | ||
| kind, name: getLlmObsSpanName(normalizedName, event.functionId), | ||
| } | ||
| if (kind === 'llm' || kind === 'embedding') { | ||
| const modelName = event.modelId | ||
| options.modelName = modelName | ||
| options.modelProvider = parseModelProvider(event.provider, modelName) | ||
| } | ||
| return options | ||
| } | ||
| /** | ||
| * @override | ||
| */ | ||
| setLLMObsTags (ctx) { | ||
| const span = ctx.currentStore?.span | ||
| if (!span) return | ||
| const { type: operation } = ctx | ||
| const kind = SPAN_NAME_TO_KIND_MAPPING[operation] | ||
| if (!kind) return | ||
| switch (operation) { | ||
| case 'embed': | ||
| this.setEmbeddingTags(span, ctx) | ||
| break | ||
| case 'generateText': | ||
| case 'streamText': | ||
| this.setTextGenerationTags(span, ctx) | ||
| break | ||
| case 'languageModelCall': | ||
| this.setLanguageModelCallTags(span, ctx) | ||
| break | ||
| case 'step': | ||
| this.setStepTags(span, ctx) | ||
| break | ||
| case 'executeTool': | ||
| this.setToolTags(span, ctx) | ||
| break | ||
| default: | ||
| break | ||
| } | ||
| } | ||
| setEmbeddingTags (span, ctx) { | ||
| const { event, result } = ctx | ||
| const input = event.value | ||
| const embedding = result?.embedding | ||
| const embeddingTextResult = embedding ? `[1 embedding(s) returned with size ${embedding.length}]` : '' | ||
| this._tagger.tagEmbeddingIO(span, input, embeddingTextResult) | ||
| this._tagger.tagMetrics(span, { | ||
| inputTokens: result?.usage?.tokens, | ||
| }) | ||
| } | ||
| setTextGenerationTags (span, ctx) { | ||
| const { event, result } = ctx | ||
| const lastUserPrompt = event.messages.findLast(message => message.role === 'user')?.content | ||
| const input = Array.isArray(lastUserPrompt) ? lastUserPrompt.map(part => part.text ?? '').join('') : lastUserPrompt | ||
| const output = | ||
| ctx.isStream | ||
| ? this.#lastOutputContentByCallId.get(event.callId)?.find(part => part.type === 'text')?.text | ||
| : result?._output | ||
| this._tagger.tagTextIO(span, input, output) | ||
| const metadata = getGenerationMetadataFromEvent(event) | ||
| this._tagger.tagMetadata(span, metadata) | ||
| if (ctx.isStream) { | ||
| this.#lastOutputContentByCallId.delete(event.callId) | ||
| } | ||
| } | ||
| setStepTags (span, ctx) { | ||
| const { result, event } = ctx | ||
| const content = ctx.isStream ? this.#lastOutputContentByCallId.get(event.callId) : result?.content | ||
| // capture reasoning if applicable | ||
| const reasoning = content?.find(part => part.type === 'reasoning')?.text | ||
| if (reasoning) { | ||
| this._tagger.tagTextIO(span, reasoning) | ||
| } | ||
| } | ||
| setLanguageModelCallTags (span, ctx) { | ||
| const { event, result } = ctx | ||
| // input messages | ||
| const { instructions, messages } = event | ||
| const inputMessages = formatLanguageModelInputMessages(instructions, messages) | ||
| // output messages | ||
| const outputMessages = | ||
| result ? formatLanguageModelOutputMessages(result.content) : [{ role: 'assistant', content: '' }] | ||
| this._tagger.tagLLMIO(span, inputMessages, outputMessages) | ||
| // tool definitions | ||
| const { tools } = event | ||
| if (Array.isArray(tools)) { | ||
| this._tagger.tagToolDefinitions( | ||
| span, | ||
| tools.map(({ inputSchema, ...rest }) => ({ | ||
| ...rest, | ||
| schema: { | ||
| properties: inputSchema?.properties ?? {}, | ||
| required: inputSchema?.required ?? [], | ||
| type: inputSchema?.type ?? '', | ||
| }, | ||
| })) | ||
| ) | ||
| } | ||
| // metadata | ||
| const metadata = getGenerationMetadataFromEvent(event) | ||
| this._tagger.tagMetadata(span, metadata) | ||
| if (!result) return | ||
| // metrics | ||
| const { usage } = result | ||
| this._tagger.tagMetrics(span, { | ||
| inputTokens: usage?.inputTokens?.total, | ||
| cacheWriteTokens: usage?.inputTokens?.cacheWrite ?? 0, | ||
| cacheReadTokens: usage?.inputTokens?.cacheRead ?? 0, | ||
| outputTokens: usage?.outputTokens?.total, | ||
| reasoningOutputTokens: usage?.outputTokens?.reasoning ?? 0, | ||
| }) | ||
| } | ||
| setToolTags (span, ctx) { | ||
| const { event, result } = ctx | ||
| const { toolCall } = event | ||
| if (!toolCall) return | ||
| this._tagger.tagTextIO(span, toolCall.input, result?.output?.output) | ||
| } | ||
| } | ||
| module.exports = VercelAiTelemetryPlugin |
| 'use strict' | ||
| const LLMObsPlugin = require('../base') | ||
| const { storage: llmobsStorage } = require('../../storage') | ||
| const { NAME, SESSION_ID } = require('../../constants/tags') | ||
| const { splitModel } = require('../../../../../datadog-plugin-claude-agent-sdk/src/util') | ||
| const subagentToolIds = new Set() | ||
| function normalizeToolOutputString (raw) { | ||
| const footerIndex = raw.search(/\s*agentId: [^\s]+ \(use SendMessage\b/) | ||
| if (footerIndex === -1) return raw | ||
| return raw.slice(0, footerIndex).trimEnd() | ||
| } | ||
| function getToolOutputText (raw) { | ||
| if (raw == null) return | ||
| if (Array.isArray(raw)) { | ||
| const output = [] | ||
| for (const block of raw) { | ||
| const text = getToolOutputText(block) | ||
| if (text) output.push(text) | ||
| } | ||
| return output.join('\n') || undefined | ||
| } | ||
| if (raw.type === 'tool_result') return getToolOutputText(raw.content) | ||
| if (raw.type === 'text') return normalizeToolOutputString(raw.text) | ||
| if (raw.type === 'tool_reference') return raw.tool_name | ||
| if (raw.content !== undefined) return getToolOutputText(raw.content) | ||
| if (typeof raw === 'string') return normalizeToolOutputString(raw) | ||
| return JSON.stringify(raw) | ||
| } | ||
| function buildOutputMessages (chunks, llmStartIdx, llmEndIdx) { | ||
| let thinking = '' | ||
| let text = '' | ||
| const toolCalls = [] | ||
| for (let i = llmStartIdx; i < llmEndIdx; i++) { | ||
| const c = chunks[i] | ||
| if (c.type !== 'assistant') continue | ||
| const block = c.message?.content?.[0] | ||
| if (block?.type === 'thinking') thinking += block.thinking ?? '' | ||
| else if (block?.type === 'text') text += block.text ?? '' | ||
| else if (block?.type === 'tool_use') { | ||
| toolCalls.push({ name: block.name, arguments: block.input ?? {}, toolId: block.id, type: block.type }) | ||
| } | ||
| } | ||
| const messages = [] | ||
| if (thinking) messages.push({ role: 'thinking', content: thinking }) | ||
| const msg = { role: 'assistant', content: text } | ||
| if (toolCalls.length) msg.toolCalls = toolCalls | ||
| messages.push(msg) | ||
| return messages | ||
| } | ||
| class QueryLLMObsPlugin extends LLMObsPlugin { | ||
| static integration = 'claude-agent-sdk' | ||
| static id = 'llmobs_claude_agent_sdk_query' | ||
| static prefix = 'tracing:orchestrion:@anthropic-ai/claude-agent-sdk:query' | ||
| getLLMObsSpanRegisterOptions (ctx) { | ||
| return { kind: 'agent' } | ||
| } | ||
| start (ctx) { | ||
| super.start(ctx) | ||
| if (!this._tracerConfig.llmobs.DD_LLMOBS_ENABLED) return | ||
| const store = llmobsStorage.getStore() | ||
| const prev = ctx.runInContext ?? (fn => fn()) | ||
| ctx.runInContext = fn => prev(() => llmobsStorage.run(store, fn)) | ||
| } | ||
| asyncEnd (ctx) { | ||
| if (!ctx.streamResolved) return | ||
| super.asyncEnd(ctx) | ||
| } | ||
| setLLMObsTags (ctx) { | ||
| const span = ctx.currentStore?.span | ||
| if (!span) return | ||
| // post-populate session_id | ||
| if (ctx.session_id) this._tagger._setTag(span, SESSION_ID, ctx.session_id) | ||
| this._tagger.tagTextIO(span, ctx.arguments?.[0]?.prompt, ctx.output) | ||
| // metadata | ||
| const { cwd, permissionMode } = ctx | ||
| const metadata = {} | ||
| if (cwd) metadata.cwd = cwd | ||
| if (permissionMode) metadata.permissionMode = permissionMode | ||
| this._tagger.tagMetadata(span, metadata) | ||
| } | ||
| } | ||
| class StepLlmObsPlugin extends LLMObsPlugin { | ||
| static integration = 'claude-agent-sdk' | ||
| static id = 'claude_agent_sdk_step_llmobs' | ||
| static system = 'claude-agent-sdk' | ||
| static prefix = 'tracing:apm:claude-agent-sdk:step' | ||
| getLLMObsSpanRegisterOptions (ctx) { | ||
| if (ctx.parentToolUseId) subagentToolIds.add(ctx.parentToolUseId) | ||
| return { kind: 'step', name: `step-${ctx.stepIndex}`, sessionId: ctx.sessionId } | ||
| } | ||
| end (ctx) { | ||
| super.end(ctx) | ||
| super.asyncEnd(ctx) | ||
| } | ||
| setLLMObsTags (ctx) { | ||
| const span = ctx.currentStore?.span | ||
| if (!span) return | ||
| const { chunks, llmStartIdx, llmEndIdx, toolOutputs } = ctx | ||
| if (!chunks) return | ||
| const outputMessages = buildOutputMessages(chunks, llmStartIdx, llmEndIdx) | ||
| const thinking = outputMessages.find(m => m.role === 'thinking')?.content ?? '' | ||
| const output = toolOutputs?.length | ||
| ? getToolOutputText(toolOutputs) | ||
| : outputMessages.find(m => m.role === 'assistant')?.content ?? '' | ||
| this._tagger.tagTextIO(span, thinking, output) | ||
| } | ||
| } | ||
| class LlmLlmObsPlugin extends LLMObsPlugin { | ||
| static integration = 'claude-agent-sdk' | ||
| static id = 'claude_agent_sdk_llm_llmobs' | ||
| static system = 'claude-agent-sdk' | ||
| static prefix = 'tracing:apm:claude-agent-sdk:llm' | ||
| getLLMObsSpanRegisterOptions (ctx) { | ||
| const { modelName, modelProvider } = splitModel(ctx.model) | ||
| return { kind: 'llm', name: ctx.model, modelName, modelProvider, sessionId: ctx.sessionId } | ||
| } | ||
| end (ctx) { | ||
| super.end(ctx) | ||
| super.asyncEnd(ctx) | ||
| } | ||
| setLLMObsTags (ctx) { | ||
| const span = ctx.currentStore?.span | ||
| if (!span) return | ||
| const { chunks, llmStartIdx, llmEndIdx, parentToolUseId, initialPrompt, usage } = ctx | ||
| if (chunks) { | ||
| const inputMessages = this.#buildInputMessages(chunks, llmStartIdx, parentToolUseId, initialPrompt) | ||
| const outputMessages = buildOutputMessages(chunks, llmStartIdx, llmEndIdx) | ||
| this._tagger.tagLLMIO(span, inputMessages, outputMessages) | ||
| } | ||
| if (usage) { | ||
| const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0 | ||
| const cacheReadTokens = usage.cache_read_input_tokens ?? 0 | ||
| const inputTokens = (usage.input_tokens ?? 0) + cacheWriteTokens + cacheReadTokens | ||
| const outputTokens = usage.output_tokens ?? 0 | ||
| this._tagger.tagMetrics(span, { | ||
| input_tokens: inputTokens, | ||
| output_tokens: outputTokens, | ||
| cache_read_input_tokens: cacheReadTokens, | ||
| cache_write_input_tokens: cacheWriteTokens, | ||
| total_tokens: inputTokens + outputTokens, | ||
| }) | ||
| } | ||
| } | ||
| #buildInputMessages (chunks, llmStartIdx, parentToolUseId, initialPrompt) { | ||
| const messages = [] | ||
| if (initialPrompt) messages.push({ role: 'user', content: initialPrompt }) | ||
| const seenIds = new Set() | ||
| for (let i = 0; i < llmStartIdx; i++) { | ||
| const c = chunks[i] | ||
| if (c.parent_tool_use_id !== parentToolUseId) continue | ||
| if (c.type === 'assistant') { | ||
| const msgId = c.message?.id | ||
| if (!msgId || seenIds.has(msgId)) continue | ||
| seenIds.add(msgId) | ||
| let thinking = '' | ||
| let text = '' | ||
| const toolCalls = [] | ||
| for (let j = i; j < llmStartIdx; j++) { | ||
| const cc = chunks[j] | ||
| if (cc.type !== 'assistant' || cc.message?.id !== msgId) break | ||
| const block = cc.message?.content?.[0] | ||
| if (block?.type === 'thinking') thinking += block.thinking ?? '' | ||
| else if (block?.type === 'text') text += block.text ?? '' | ||
| else if (block?.type === 'tool_use') { | ||
| toolCalls.push({ name: block.name, arguments: block.input ?? {}, toolId: block.id, type: block.type }) | ||
| } | ||
| } | ||
| if (thinking) messages.push({ role: 'thinking', content: thinking }) | ||
| const msg = { role: 'assistant', content: text } | ||
| if (toolCalls.length) msg.toolCalls = toolCalls | ||
| messages.push(msg) | ||
| } else if (c.type === 'user') { | ||
| const content = c.message?.content | ||
| if (!content) continue | ||
| for (const block of content) { | ||
| if (block.type === 'text') { | ||
| messages.push({ role: 'user', content: block.text ?? '' }) | ||
| } else if (block.type === 'tool_result') { | ||
| const text = getToolOutputText(block.content) ?? '' | ||
| messages.push({ role: 'tool', content: text }) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return messages | ||
| } | ||
| } | ||
| class ToolLlmObsPlugin extends LLMObsPlugin { | ||
| static integration = 'claude-agent-sdk' | ||
| static id = 'claude_agent_sdk_tool_llmobs' | ||
| static system = 'claude-agent-sdk' | ||
| static prefix = 'tracing:apm:claude-agent-sdk:tool' | ||
| getLLMObsSpanRegisterOptions (ctx) { | ||
| return { kind: 'tool', name: ctx.name, sessionId: ctx.sessionId } | ||
| } | ||
| end (ctx) { | ||
| super.end(ctx) | ||
| super.asyncEnd(ctx) | ||
| } | ||
| setLLMObsTags (ctx) { | ||
| const span = ctx.currentStore?.span | ||
| if (!span) return | ||
| if (subagentToolIds.has(ctx.id)) { | ||
| subagentToolIds.delete(ctx.id) | ||
| const description = ctx.input?.description | ||
| this._tagger.changeKind(span, 'agent') | ||
| if (description) this._tagger._setTag(span, NAME, `${ctx.name} (${description})`) | ||
| const output = getToolOutputText(ctx.output) | ||
| this._tagger.tagTextIO(span, ctx.input?.prompt, output) | ||
| return | ||
| } | ||
| const input = ctx.input ? JSON.stringify(ctx.input) : undefined | ||
| const output = getToolOutputText(ctx.output) | ||
| this._tagger.tagTextIO(span, input, output) | ||
| } | ||
| } | ||
| module.exports = [ | ||
| QueryLLMObsPlugin, | ||
| StepLlmObsPlugin, | ||
| ToolLlmObsPlugin, | ||
| LlmLlmObsPlugin, | ||
| ] |
| 'use strict' | ||
| function splitModel (model) { | ||
| if (!model) return { modelName: undefined, modelProvider: 'anthropic' } | ||
| const idx = model.indexOf('/') | ||
| if (idx === -1) return { modelName: model, modelProvider: 'anthropic' } | ||
| return { modelName: model.slice(idx + 1), modelProvider: model.slice(0, idx) } | ||
| } | ||
| module.exports = { splitModel } |
@@ -47,4 +47,2 @@ "component","origin","license","copyright" | ||
| "@types/estree","https://github.com/DefinitelyTyped/DefinitelyTyped","['MIT']","['DefinitelyTyped']" | ||
| "acorn","https://github.com/acornjs/acorn","['MIT']","['acornjs']" | ||
| "acorn-import-attributes","https://github.com/xtuc/acorn-import-attributes","['MIT']","['Sven Sauleau']" | ||
| "argparse","https://github.com/nodeca/argparse","['Python-2.0']","['nodeca']" | ||
@@ -57,2 +55,3 @@ "astring","https://github.com/davidbonnet/astring","['MIT']","['David Bonnet']" | ||
| "detect-newline","https://github.com/sindresorhus/detect-newline","['MIT']","['Sindre Sorhus']" | ||
| "es-module-lexer","https://github.com/guybedford/es-module-lexer","['MIT']","['Guy Bedford']" | ||
| "escape-string-regexp","https://github.com/sindresorhus/escape-string-regexp","['MIT']","['Sindre Sorhus']" | ||
@@ -59,0 +58,0 @@ "esquery","https://github.com/estools/esquery","['BSD-3-Clause']","['Joel Feenstra']" |
+32
-29
| { | ||
| "name": "dd-trace", | ||
| "version": "5.111.0", | ||
| "version": "5.112.0", | ||
| "description": "Datadog APM tracing client for JavaScript", | ||
@@ -9,3 +9,3 @@ "main": "index.js", | ||
| "env": "bash ./plugin-env", | ||
| "prepare": "node scripts/patch-istanbul-lib-coverage.js && cd vendor && npm ci --include=dev", | ||
| "prepare": "node scripts/patch-istanbul-lib-coverage.js && node scripts/patch-v8-to-istanbul.js && cd vendor && npm ci --include=dev", | ||
| "prepack": "node scripts/release/swap-v5-types.js", | ||
@@ -26,3 +26,3 @@ "bench": "node benchmark/index.js", | ||
| "lint:codeowners": "codeowners-audit", | ||
| "lint:codeowners:ci": "codeowners-audit --glob='**/*.spec.js' --glob='benchmark/sirun/**' --glob='.agents/**' --glob='.claude/**'", | ||
| "lint:codeowners:ci": "codeowners-audit --glob='**/*.spec.js' --glob='benchmark/sirun/**' --glob='.agents/**' --glob='.claude/**' --glob='integration-tests/**'", | ||
| "lint:editorconfig": "editorconfig-checker", | ||
@@ -33,40 +33,40 @@ "release:proposal": "node scripts/release/proposal", | ||
| "test:aiguard": "mocha \"packages/dd-trace/test/aiguard/**/*.spec.js\"", | ||
| "test:aiguard:ci": "nyc --silent node init && nyc -- npm run test:aiguard", | ||
| "test:aiguard:ci": "node scripts/c8-ci.js test:aiguard", | ||
| "test:appsec": "mocha --exclude \"packages/dd-trace/test/appsec/**/*.plugin.spec.js\" \"packages/dd-trace/test/appsec/**/*.spec.js\"", | ||
| "test:appsec:ci": "nyc --silent node init && nyc -- npm run test:appsec", | ||
| "test:appsec:ci": "node scripts/c8-ci.js test:appsec", | ||
| "test:appsec:plugins": "mocha \"packages/dd-trace/test/appsec/**/*.@(${PLUGINS}).plugin.spec.js\"", | ||
| "test:appsec:plugins:ci": "yarn services && nyc --silent node init && nyc -- npm run test:appsec:plugins", | ||
| "test:appsec:plugins:ci": "yarn services && node scripts/c8-ci.js test:appsec:plugins", | ||
| "test:debugger": "mocha \"packages/dd-trace/test/debugger/**/*.spec.js\"", | ||
| "test:debugger:ci": "nyc --silent node init && nyc -- npm run test:debugger", | ||
| "test:debugger:ci": "node scripts/c8-ci.js test:debugger", | ||
| "test:eslint-rules": "node eslint-rules/*.test.mjs", | ||
| "test:trace:core": "node scripts/mocha-parallel-files.js --expose-gc --timeout 30000 -- \"packages/dd-trace/test/*.spec.js\" \"packages/dd-trace/test/{agent,ci-visibility,config,crashtracking,datastreams,encode,exporters,msgpack,opentelemetry,opentracing,payload-tagging,plugins,remote_config,service-naming,standalone,telemetry,external-logger}/**/*.spec.js\"", | ||
| "test:trace:core:ci": "nyc --silent node init && nyc -- npm run test:trace:core", | ||
| "test:trace:core:ci": "node scripts/c8-ci.js test:trace:core", | ||
| "test:trace:guardrails": "mocha \"packages/dd-trace/test/guardrails/**/*.spec.js\"", | ||
| "test:trace:guardrails:ci": "nyc --silent node init && nyc -- npm run test:trace:guardrails", | ||
| "test:trace:guardrails:ci": "node scripts/c8-ci.js test:trace:guardrails", | ||
| "test:esbuild": "mocha \"packages/datadog-esbuild/test/**/*.spec.js\"", | ||
| "test:esbuild:ci": "nyc --silent node init && nyc -- npm run test:esbuild", | ||
| "test:esbuild:ci": "node scripts/c8-ci.js test:esbuild", | ||
| "test:webpack": "mocha \"packages/datadog-webpack/test/**/*.spec.js\"", | ||
| "test:webpack:ci": "nyc --silent node init && nyc -- npm run test:webpack", | ||
| "test:webpack:ci": "node scripts/c8-ci.js test:webpack", | ||
| "test:instrumentations": "mocha \"packages/datadog-instrumentations/test/@(${PLUGINS}).spec.js\"", | ||
| "test:instrumentations:ci": "yarn services && nyc --silent node init && nyc -- npm run test:instrumentations", | ||
| "test:instrumentations:ci": "yarn services && node scripts/c8-ci.js test:instrumentations", | ||
| "test:instrumentations:misc": "mocha \"packages/datadog-instrumentations/test/*/**/*.spec.{js,mjs}\"", | ||
| "test:instrumentations:misc:ci": "nyc --silent node init && nyc -- npm run test:instrumentations:misc", | ||
| "test:instrumentations:misc:ci": "node scripts/c8-ci.js test:instrumentations:misc", | ||
| "test:core": "node scripts/mocha-parallel-files.js --expose-gc --timeout 30000 -- \"packages/datadog-core/test/**/*.spec.js\"", | ||
| "test:core:ci": "nyc --silent node init && nyc -- npm run test:core", | ||
| "test:core:ci": "node scripts/c8-ci.js test:core", | ||
| "test:code-origin": "mocha \"packages/datadog-code-origin/test/**/*.spec.js\"", | ||
| "test:code-origin:ci": "nyc --silent node init && nyc -- npm run test:code-origin", | ||
| "test:code-origin:ci": "node scripts/c8-ci.js test:code-origin", | ||
| "test:lambda": "mocha \"packages/dd-trace/test/lambda/**/*.spec.js\"", | ||
| "test:lambda:ci": "nyc --silent node init && nyc -- npm run test:lambda", | ||
| "test:lambda:ci": "node scripts/c8-ci.js test:lambda", | ||
| "test:llmobs:sdk": "mocha --exclude \"packages/dd-trace/test/llmobs/plugins/**/*.spec.js\" \"packages/dd-trace/test/llmobs/**/*.spec.js\"", | ||
| "test:llmobs:sdk:ci": "nyc --silent node init && nyc -- npm run test:llmobs:sdk", | ||
| "test:llmobs:sdk:ci": "node scripts/c8-ci.js test:llmobs:sdk", | ||
| "test:llmobs:plugins": "mocha \"packages/dd-trace/test/llmobs/plugins/@(${PLUGINS})/*.spec.js\"", | ||
| "test:llmobs:plugins:ci": "yarn services && nyc --silent node init && nyc -- npm run test:llmobs:plugins", | ||
| "test:llmobs:plugins:ci": "yarn services && node scripts/c8-ci.js test:llmobs:plugins", | ||
| "test:openfeature": "mocha \"packages/dd-trace/test/openfeature/**/*.spec.js\"", | ||
| "test:openfeature:ci": "nyc --silent node init && nyc -- npm run test:openfeature", | ||
| "test:openfeature:ci": "node scripts/c8-ci.js test:openfeature", | ||
| "test:plugins": "node --expose-gc ./node_modules/mocha/bin/mocha.js \"packages/datadog-plugin-@(${PLUGINS})/test/**/${SPEC:-*}*.spec.js\"", | ||
| "test:plugins:ci": "yarn services && nyc --silent node init && nyc -- npm run test:plugins", | ||
| "test:plugins:ci:flaky": "yarn services && nyc --silent node init && nyc -- npm run test:plugins -- --bail --retries 2", | ||
| "test:plugins:ci": "yarn services && node scripts/c8-ci.js test:plugins", | ||
| "test:plugins:ci:flaky": "yarn services && node scripts/c8-ci.js test:plugins --bail --retries 2", | ||
| "test:plugins:upstream": "node ./packages/dd-trace/test/plugins/suite.js", | ||
| "test:profiler": "node scripts/mocha-parallel-files.js --expose-gc --timeout 30000 -- \"packages/dd-trace/test/profiling/**/*.spec.js\"", | ||
| "test:profiler:ci": "nyc --silent node init && nyc -- npm run test:profiler", | ||
| "test:profiler:ci": "node scripts/c8-ci.js test:profiler", | ||
| "test:integration": "mocha --timeout 60000 \"integration-tests/*.spec.js\"", | ||
@@ -112,3 +112,3 @@ "test:integration:coverage": "node ./integration-tests/coverage/run-suite.js --timeout 60000 \"integration-tests/*.spec.js\"", | ||
| "test:shimmer": "mocha \"packages/datadog-shimmer/test/**/*.spec.js\"", | ||
| "test:shimmer:ci": "nyc --silent node init && nyc -- npm run test:shimmer", | ||
| "test:shimmer:ci": "node scripts/c8-ci.js test:shimmer", | ||
| "verify:workflow-job-names": "node scripts/verify-workflow-job-names.js", | ||
@@ -171,3 +171,3 @@ "verify-exercised-tests": "node scripts/verify-exercised-tests.js" | ||
| "dc-polyfill": "^0.1.11", | ||
| "import-in-the-middle": "^3.2.0", | ||
| "import-in-the-middle": "^3.3.1", | ||
| "opentracing": ">=0.14.7" | ||
@@ -204,10 +204,11 @@ }, | ||
| "bun": "1.3.14", | ||
| "c8": "^11.0.0", | ||
| "codeowners-audit": "^2.9.0", | ||
| "editorconfig-checker": "^6.1.1", | ||
| "eslint": "^9.39.2", | ||
| "eslint-plugin-cypress": "^6.4.1", | ||
| "eslint-plugin-cypress": "^6.4.2", | ||
| "eslint-plugin-import": "^2.32.0", | ||
| "eslint-plugin-jsdoc": "^63.0.8", | ||
| "eslint-plugin-jsdoc": "^63.0.10", | ||
| "eslint-plugin-mocha": "^11.3.0", | ||
| "eslint-plugin-n": "^18.2.0", | ||
| "eslint-plugin-n": "^18.2.1", | ||
| "eslint-plugin-promise": "^7.3.0", | ||
@@ -228,3 +229,3 @@ "eslint-plugin-sonarjs": "^4.1.0", | ||
| "multer": "^2.2.0", | ||
| "nock": "^14.0.15", | ||
| "nock": "^14.0.16", | ||
| "node-preload": "^0.2.1", | ||
@@ -239,5 +240,7 @@ "nyc": "^18.0.0", | ||
| "sinon": "^22.0.0", | ||
| "test-exclude": "^8.0.0", | ||
| "tiktoken": "^1.0.21", | ||
| "typescript": "^6.0.3", | ||
| "workerpool": "^10.0.2", | ||
| "v8-to-istanbul": "^9.0.0", | ||
| "workerpool": "^10.0.3", | ||
| "yaml": "^2.9.0", | ||
@@ -244,0 +247,0 @@ "yarn-deduplicate": "^6.0.2" |
@@ -236,2 +236,47 @@ 'use strict' | ||
| const aiSdkTelemetryChannel = tracingChannel('ai:telemetry') | ||
| const aiSdkTelemetryStreamedChunkChannel = channel('dd-trace:vercel-ai:chunk') | ||
| // for testing, and possibly actual instrumentation use, we want to | ||
| // guard against double-subscribing to the asyncEnd channel of the | ||
| // vercel ai-provided tracingChannel | ||
| let subscribed = false | ||
| // as of the v7 release, the ai sdk does not automatically aggregate streamed responses | ||
| // we will handle emitting the chunks directly for products to handle | ||
| addHook({ name: 'ai', versions: ['>=7.0.0'] }, exports => { | ||
| if (subscribed) return exports | ||
| subscribed = true | ||
| // ai sdk v7 only supported on node.js 22+ | ||
| // inlining this import here so we only import in those cases | ||
| // eslint-disable-next-line n/no-unsupported-features/node-builtins | ||
| const { TransformStream } = require('node:stream/web') | ||
| aiSdkTelemetryChannel.subscribe({ | ||
| asyncEnd (ctx) { | ||
| // guard against this event being re-emitted. | ||
| if (!ctx.isStream || !ctx.result?.stream || ctx.streamConsumed) return | ||
| const transform = new TransformStream({ | ||
| transform (chunk, controller) { | ||
| const done = chunk.type === 'finish' | ||
| aiSdkTelemetryStreamedChunkChannel.publish({ ctx, chunk, done }) | ||
| if (done) { | ||
| aiSdkTelemetryChannel.asyncEnd.publish(ctx) | ||
| } | ||
| controller.enqueue(chunk) // pass through value | ||
| }, | ||
| }) | ||
| ctx.result.stream = ctx.result.stream.pipeThrough(transform) | ||
| }, | ||
| }) | ||
| return exports | ||
| }) | ||
| module.exports = { wrapModelWithLifecycle } |
@@ -115,7 +115,10 @@ 'use strict' | ||
| addHook({ name: '@apollo/server', file: 'dist/cjs/ApolloServer.js', versions: ['4'] }, apolloServerHook) | ||
| addHook({ name: '@apollo/server', file: 'dist/cjs/ApolloServer.js', versions: ['>=4'] }, apolloServerHook) | ||
| addHook({ name: '@apollo/server', file: 'dist/cjs/express4/index.js', versions: ['4'] }, apolloExpress4Hook) | ||
| addHook({ name: '@apollo/server', file: 'dist/cjs/utils/HeaderMap.js', versions: ['4'] }, apolloHeaderMapHook) | ||
| addHook( | ||
| { name: '@apollo/server', file: 'dist/cjs/utils/HeaderMap.js', versions: ['>=4'] }, | ||
| apolloHeaderMapHook | ||
| ) | ||
@@ -126,12 +129,1 @@ addHook( | ||
| ) | ||
| addHook( | ||
| { name: '@apollo/server', file: 'dist/cjs/runHttpQuery.js', versions: ['>=5.0.0'] }, | ||
| (runHttpQueryModule) => { | ||
| shimmer.wrap(runHttpQueryModule, 'runHttpQuery', function wrapRunHttpQuery (originalRunHttpQuery) { | ||
| return wrapExecuteHTTPGraphQLRequest(originalRunHttpQuery) | ||
| }) | ||
| return runHttpQueryModule | ||
| } | ||
| ) |
@@ -132,3 +132,3 @@ 'use strict' | ||
| const { start, end, asyncStart, asyncEnd, error } = childProcessChannel | ||
| const { start, end, asyncStart, asyncEnd, error: errorChannel } = childProcessChannel | ||
| start.publish(context) | ||
@@ -144,3 +144,3 @@ | ||
| context.error = error | ||
| error.publish(context) | ||
| errorChannel.publish(context) | ||
| throw error | ||
@@ -154,3 +154,3 @@ } finally { | ||
| context.error = err | ||
| error.publish(context) | ||
| errorChannel.publish(context) | ||
| asyncStart.publish(context) | ||
@@ -157,0 +157,0 @@ |
@@ -87,2 +87,3 @@ 'use strict' | ||
| const modifiedTestsByPickleId = new Map() | ||
| const runnerToRetryState = new WeakMap() | ||
| // Pickle IDs for tests that are genuinely new (not in known tests list). | ||
@@ -666,2 +667,71 @@ const newTestPickleIds = new Set() | ||
| async function handleRetriedAttempt (runner, state) { | ||
| const { promises } = state | ||
| if (promises.hitBreakpointPromise) { | ||
| await promises.hitBreakpointPromise | ||
| } | ||
| const setProbePromise = publishRetriedAttempt(runner, state) | ||
| if (setProbePromise) { | ||
| await setProbePromise | ||
| promises.setProbePromise = undefined | ||
| } | ||
| startRetriedAttempt(state) | ||
| } | ||
| function handleRetriedAttemptFromEnvelope (runner, state) { | ||
| publishRetriedAttempt(runner, state) | ||
| startRetriedAttempt(state) | ||
| } | ||
| function publishRetriedAttempt (runner, state) { | ||
| const { promises } = state | ||
| let error | ||
| try { | ||
| const cucumberResult = runner.getWorstStepResult() | ||
| error = getErrorFromCucumberResult(cucumberResult) | ||
| } catch { | ||
| // ignore error | ||
| } | ||
| const currentAttempt = state.numAttempt | ||
| const nextAttempt = currentAttempt + 1 | ||
| const failedAttemptCtx = numAttemptToCtx.get(currentAttempt) | ||
| const isFirstAttempt = currentAttempt === 0 | ||
| const isAtrRetry = !isFirstAttempt && isFlakyTestRetriesEnabled | ||
| // ATR: record this attempt as failed so when run().finally runs (after retry) we have all statuses | ||
| if (isFlakyTestRetriesEnabled) { | ||
| const nameForKey = runner.pickle.name.replace(/\s*\(attempt \d+(?:, retried)?\)\s*$/, '') | ||
| const atrKey = `${runner.pickle.uri}:${nameForKey}` | ||
| if (atrStatusesByScenarioKey.has(atrKey)) { | ||
| atrStatusesByScenarioKey.get(atrKey).push('fail') | ||
| } else { | ||
| atrStatusesByScenarioKey.set(atrKey, ['fail']) | ||
| } | ||
| } | ||
| // the current span will be finished and a new one will be created | ||
| testRetryCh.publish({ | ||
| isFirstAttempt, | ||
| error, | ||
| isAtrRetry, | ||
| promises, | ||
| canWaitForDi: state.testStartPayload.canWaitForDi, | ||
| ...failedAttemptCtx.currentStore, | ||
| }) | ||
| state.numAttempt = nextAttempt | ||
| return promises.setProbePromise | ||
| } | ||
| function startRetriedAttempt (state) { | ||
| const { promises, testStartPayload } = state | ||
| const newCtx = { ...testStartPayload, promises } | ||
| numAttemptToCtx.set(state.numAttempt, newCtx) | ||
| testStartCh.runStores(newCtx, () => {}) | ||
| } | ||
| function wrapRun (pl, isLatestVersion, version) { | ||
@@ -672,2 +742,14 @@ if (patched.has(pl)) return | ||
| const canAwaitRetries = typeof pl.prototype.runAttempt === 'function' | ||
| if (canAwaitRetries) { | ||
| shimmer.wrap(pl.prototype, 'runAttempt', runAttempt => async function (...args) { | ||
| const willBeRetried = await runAttempt.apply(this, args) | ||
| const state = runnerToRetryState.get(this) | ||
| if (willBeRetried && state) { | ||
| await handleRetriedAttempt(this, state) | ||
| } | ||
| return willBeRetried | ||
| }) | ||
| } | ||
| shimmer.wrap(pl.prototype, 'run', run => function (...args) { | ||
@@ -678,4 +760,2 @@ if (!testFinishCh.hasSubscribers) { | ||
| let numAttempt = 0 | ||
| const testFileAbsolutePath = this.pickle.uri | ||
@@ -691,5 +771,11 @@ const testSuitePath = getTestSuitePath(testFileAbsolutePath, process.cwd()) | ||
| isParallel: !!getEnvironmentVariable('CUCUMBER_WORKER_ID'), | ||
| // Older Cucumber runners do not expose an awaited retry boundary. Failed Test Replay | ||
| // intentionally skips DI setup there instead of bringing back the synchronous wait. | ||
| canWaitForDi: canAwaitRetries, | ||
| } | ||
| const ctx = testStartPayload | ||
| numAttemptToCtx.set(numAttempt, ctx) | ||
| const promises = {} | ||
| const state = { numAttempt: 0, promises, testStartPayload } | ||
| numAttemptToCtx.set(state.numAttempt, ctx) | ||
| runnerToRetryState.set(this, state) | ||
| if (isTestManagementTestsEnabled && getTestProperties(testSuitePath, this.pickle.name).attemptToFix) { | ||
@@ -699,5 +785,6 @@ logAttemptToFixTestExecution(testSuitePath, this.pickle.name, loggedAttemptToFixTests) | ||
| testStartCh.runStores(ctx, () => {}) | ||
| const promises = {} | ||
| try { | ||
| const onEnvelope = async (testCase) => { | ||
| const onEnvelope = (testCase) => { | ||
| if (canAwaitRetries) return | ||
| // Only supported from >=8.0.0 | ||
@@ -707,40 +794,9 @@ if (testCase?.testCaseFinished) { | ||
| if (willBeRetried) { // test case failed and will be retried | ||
| let error | ||
| try { | ||
| const cucumberResult = this.getWorstStepResult() | ||
| error = getErrorFromCucumberResult(cucumberResult) | ||
| } catch { | ||
| // ignore error | ||
| } | ||
| const failedAttemptCtx = numAttemptToCtx.get(numAttempt) | ||
| const isFirstAttempt = numAttempt++ === 0 | ||
| const isAtrRetry = !isFirstAttempt && isFlakyTestRetriesEnabled | ||
| // ATR: record this attempt as failed so when run().finally runs (after retry) we have all statuses | ||
| if (isFlakyTestRetriesEnabled) { | ||
| const nameForKey = this.pickle.name.replace(/\s*\(attempt \d+(?:, retried)?\)\s*$/, '') | ||
| const atrKey = `${this.pickle.uri}:${nameForKey}` | ||
| if (atrStatusesByScenarioKey.has(atrKey)) { | ||
| atrStatusesByScenarioKey.get(atrKey).push('fail') | ||
| } else { | ||
| atrStatusesByScenarioKey.set(atrKey, ['fail']) | ||
| } | ||
| } | ||
| if (promises.hitBreakpointPromise) { | ||
| await promises.hitBreakpointPromise | ||
| } | ||
| // the current span will be finished and a new one will be created | ||
| testRetryCh.publish({ isFirstAttempt, error, isAtrRetry, ...failedAttemptCtx.currentStore }) | ||
| const newCtx = { ...testStartPayload, promises } | ||
| numAttemptToCtx.set(numAttempt, newCtx) | ||
| testStartCh.runStores(newCtx, () => {}) | ||
| handleRetriedAttemptFromEnvelope(this, state) | ||
| } | ||
| } | ||
| } | ||
| this.eventBroadcaster.on('envelope', onEnvelope) | ||
| if (!canAwaitRetries) { | ||
| this.eventBroadcaster.on('envelope', onEnvelope) | ||
| } | ||
| let promise | ||
@@ -753,3 +809,6 @@ const executionStart = performance.now() | ||
| promise.finally(async () => { | ||
| this.eventBroadcaster.removeListener('envelope', onEnvelope) | ||
| if (!canAwaitRetries) { | ||
| this.eventBroadcaster.removeListener('envelope', onEnvelope) | ||
| } | ||
| runnerToRetryState.delete(this) | ||
| const result = this.getWorstStepResult() | ||
@@ -862,3 +921,3 @@ const { status, skipReason } = isLatestVersion | ||
| const attemptCtx = numAttemptToCtx.get(numAttempt) | ||
| const attemptCtx = numAttemptToCtx.get(state.numAttempt) | ||
@@ -911,3 +970,3 @@ const error = getErrorFromCucumberResult(result) | ||
| isEfdRetry, | ||
| isFlakyRetry: numAttempt > 0, | ||
| isFlakyRetry: state.numAttempt > 0, | ||
| isAttemptToFix, | ||
@@ -914,0 +973,0 @@ isAttemptToFixRetry, |
@@ -116,5 +116,6 @@ 'use strict' | ||
| * @param {Function[]} userAfterRunHandlers user's after:run handlers collected from wrappedOn | ||
| * @param {Function[]} userAfterScreenshotHandlers user's after:screenshot handlers collected from wrappedOn | ||
| * @returns {object} the config object (possibly modified) | ||
| */ | ||
| function registerDdTraceHooks (on, config, userAfterSpecHandlers, userAfterRunHandlers) { | ||
| function registerDdTraceHooks (on, config, userAfterSpecHandlers, userAfterRunHandlers, userAfterScreenshotHandlers) { | ||
| const wrapperFile = injectSupportFile(config) | ||
@@ -140,2 +141,3 @@ | ||
| for (const h of userAfterSpecHandlers) on('after:spec', h) | ||
| for (const h of userAfterScreenshotHandlers) on('after:screenshot', h) | ||
| registerAfterRunWithCleanup() | ||
@@ -158,2 +160,3 @@ on('task', noopTask) | ||
| userAfterRunHandlers, | ||
| userAfterScreenshotHandlers, | ||
| cleanupWrapper, | ||
@@ -182,2 +185,3 @@ registered: false, | ||
| const userAfterRunHandlers = [] | ||
| const userAfterScreenshotHandlers = [] | ||
@@ -189,2 +193,4 @@ const wrappedOn = (event, handler) => { | ||
| userAfterRunHandlers.push(handler) | ||
| } else if (event === 'after:screenshot') { | ||
| userAfterScreenshotHandlers.push(handler) | ||
| } else { | ||
@@ -205,3 +211,4 @@ on(event, handler) | ||
| userAfterSpecHandlers, | ||
| userAfterRunHandlers | ||
| userAfterRunHandlers, | ||
| userAfterScreenshotHandlers | ||
| ) | ||
@@ -215,3 +222,4 @@ }) | ||
| userAfterSpecHandlers, | ||
| userAfterRunHandlers | ||
| userAfterRunHandlers, | ||
| userAfterScreenshotHandlers | ||
| ) | ||
@@ -218,0 +226,0 @@ } |
@@ -25,2 +25,4 @@ 'use strict' | ||
| const bodyPublished = new WeakSet() | ||
| let lastPublishedError | ||
| let lastPublishedReq | ||
@@ -114,5 +116,8 @@ function wrapFastify (fastify, hasParsingEvents) { | ||
| if (promise && typeof promise.catch === 'function') { | ||
| return promise.catch(error => { | ||
| // Observe the rejection to publish, then hand back the original promise so | ||
| // the rejection keeps propagating untouched. Returning the handler's | ||
| // promise instead would resolve with `undefined` and swallow the rejection. | ||
| promise.catch(error => { | ||
| ctx.error = error | ||
| return publishError(ctx) | ||
| publishError(ctx) | ||
| }) | ||
@@ -124,3 +129,4 @@ } | ||
| ctx.error = error | ||
| throw publishError(ctx) | ||
| publishError(ctx) | ||
| throw error | ||
| } | ||
@@ -311,7 +317,20 @@ } | ||
| function publishError (ctx) { | ||
| if (ctx.error) { | ||
| publishErrorChannel(ctx) | ||
| } | ||
| const error = ctx.error | ||
| if (!error) return | ||
| return ctx.error | ||
| // avvio's boot loop (`_encapsulateThreeParam`) re-invokes the same encapsulated | ||
| // hook after it throws, re-throwing the same error object on every sequential | ||
| // re-drive (#9099), recursing the subscriber until boot overflows the stack. | ||
| // The subscribers tag once per request, so the guard collapses only a re-drive | ||
| // of the same error against the same request; a distinct request reusing a | ||
| // cached error object still publishes. Boot hooks carry no request, so their | ||
| // re-drives share the same `undefined` req and collapse after the first. The | ||
| // re-drive re-throws the one caught error on the trailing hop, so a compare | ||
| // against the previous publish bounds it without a per-error side table. | ||
| const req = ctx.req | ||
| if (error === lastPublishedError && req === lastPublishedReq) return | ||
| lastPublishedError = error | ||
| lastPublishedReq = req | ||
| publishErrorChannel(ctx) | ||
| } | ||
@@ -318,0 +337,0 @@ |
@@ -18,2 +18,3 @@ 'use strict' | ||
| // Non Node.js modules | ||
| '@anthropic-ai/claude-agent-sdk': { esmFirst: true, fn: () => require('../claude-agent-sdk') }, | ||
| '@anthropic-ai/sdk': { esmFirst: true, fn: () => require('../anthropic') }, | ||
@@ -20,0 +21,0 @@ '@apollo/server': () => require('../apollo-server'), |
@@ -54,2 +54,9 @@ 'use strict' | ||
| * | ||
| * The flag bounds only synchronous re-entry. A sequential re-drive of the same | ||
| * error (fastify's avvio boot loop) runs after the publish returned and the | ||
| * `finally` cleared the flag, so the framework that produces that shape guards | ||
| * it at its own seam; the middleware frameworks that republish the same error | ||
| * once per unwound layer (koa, router, connect, restify, each tagging a | ||
| * distinct span) must keep publishing it. | ||
| * | ||
| * @param {Channel} errorChannel | ||
@@ -56,0 +63,0 @@ */ |
@@ -8,3 +8,3 @@ 'use strict' | ||
| name: 'ai', | ||
| versionRange: '>=4.0.0', | ||
| versionRange: '>=4.0.0 <7.0.0', | ||
| filePath: 'dist/index.js', | ||
@@ -21,3 +21,3 @@ }, | ||
| name: 'ai', | ||
| versionRange: '>=4.0.0', | ||
| versionRange: '>=4.0.0 <7.0.0', | ||
| filePath: 'dist/index.mjs', | ||
@@ -59,3 +59,3 @@ }, | ||
| name: 'ai', | ||
| versionRange: '>=6.0.0', | ||
| versionRange: '>=6.0.0 <7.0.0', | ||
| filePath: 'dist/index.js', | ||
@@ -72,3 +72,3 @@ }, | ||
| name: 'ai', | ||
| versionRange: '>=6.0.0', | ||
| versionRange: '>=6.0.0 <7.0.0', | ||
| filePath: 'dist/index.mjs', | ||
@@ -111,3 +111,3 @@ }, | ||
| name: 'ai', | ||
| versionRange: '>=4.0.0', | ||
| versionRange: '>=4.0.0 <7.0.0', | ||
| filePath: 'dist/index.js', | ||
@@ -124,3 +124,3 @@ }, | ||
| name: 'ai', | ||
| versionRange: '>=4.0.0', | ||
| versionRange: '>=4.0.0 <7.0.0', | ||
| filePath: 'dist/index.mjs', | ||
@@ -127,0 +127,0 @@ }, |
@@ -7,2 +7,3 @@ 'use strict' | ||
| ...require('./bullmq'), | ||
| ...require('./claude-agent-sdk'), | ||
| ...require('./graphql'), | ||
@@ -9,0 +10,0 @@ ...require('./langchain'), |
@@ -39,2 +39,3 @@ 'use strict' | ||
| getOnHookEndHandler, | ||
| patchFailedTestReplayHookUp, | ||
| getOnFailHandler, | ||
@@ -386,2 +387,6 @@ getOnPendingHandler, | ||
| function isFailedTestReplayEnabled () { | ||
| return config.isTestDynamicInstrumentationEnabled && config.isDiEnabled | ||
| } | ||
| function getExecutionConfiguration (runner, isParallel, frameworkVersion, onFinishRequest, localSuites) { | ||
@@ -476,3 +481,3 @@ const ctx = { | ||
| const onReceivedConfiguration = ({ err, libraryConfig, repositoryRoot }) => { | ||
| const onReceivedConfiguration = ({ err, isTestDynamicInstrumentationEnabled, libraryConfig, repositoryRoot }) => { | ||
| if (err || !skippableSuitesCh.hasSubscribers || !knownTestsCh.hasSubscribers) { | ||
@@ -498,2 +503,4 @@ return mochaGlobalRunCh.runStores(ctx, () => { | ||
| config.flakyTestRetriesCount = libraryConfig.flakyTestRetriesCount | ||
| config.isDiEnabled = libraryConfig.isDiEnabled | ||
| config.isTestDynamicInstrumentationEnabled = isTestDynamicInstrumentationEnabled | ||
@@ -562,2 +569,5 @@ getTestOptimizationRequestResults({ | ||
| getExecutionConfiguration(runner, false, frameworkVersion, () => { | ||
| if (isFailedTestReplayEnabled()) { | ||
| patchFailedTestReplayHookUp(runner.constructor) | ||
| } | ||
| if (config.isKnownTestsEnabled) { | ||
@@ -788,3 +798,9 @@ const testSuites = this.files.map(file => getTestSuitePath(file, process.cwd())) | ||
| // If the hook passes, 'hook end' will be emitted. Otherwise, 'fail' will be emitted | ||
| this.on('hook end', getOnHookEndHandler(config)) | ||
| this.on('hook end', getOnHookEndHandler(config, { | ||
| onStart: incrementPendingRootFinalization, | ||
| onFinish: function (test) { | ||
| finishRootSuiteAfterFinalAttempt(test) | ||
| decrementPendingRootFinalization(test) | ||
| }, | ||
| })) | ||
@@ -1104,3 +1120,4 @@ this.on('hook end', function (hook) { | ||
| !config.isImpactedTestsEnabled && | ||
| !config.isFlakyTestRetriesEnabled)) { | ||
| !config.isFlakyTestRetriesEnabled && | ||
| !isFailedTestReplayEnabled())) { | ||
| return run.apply(this, arguments) | ||
@@ -1156,2 +1173,6 @@ } | ||
| if (isFailedTestReplayEnabled()) { | ||
| newWorkerArgs._ddIsFailedTestReplayEnabled = true | ||
| } | ||
| // We pass the known tests for the test file to the worker | ||
@@ -1158,0 +1179,0 @@ const testFileResult = await run.apply( |
@@ -5,5 +5,2 @@ 'use strict' | ||
| // Capture real timers at module load time, before any test can install fake timers. | ||
| const realSetTimeout = setTimeout | ||
| const { | ||
@@ -23,2 +20,3 @@ getTestSuitePath, | ||
| const testFinishCh = channel('ci:mocha:test:finish') | ||
| const testDiWaitCh = channel('ci:mocha:test:di:wait') | ||
| // after a test has failed, we'll publish to this channel | ||
@@ -33,3 +31,2 @@ const testRetryCh = channel('ci:mocha:test:retry') | ||
| const BREAKPOINT_HIT_GRACE_PERIOD_MS = 200 | ||
| const testToContext = new WeakMap() | ||
@@ -49,2 +46,8 @@ const originalFns = new WeakMap() | ||
| const attemptToFixExecutions = new Map() | ||
| function waitForHitProbe () { | ||
| const promises = {} | ||
| testDiWaitCh.publish({ promises }) | ||
| return promises.hitBreakpointPromise | ||
| } | ||
| const loggedAttemptToFixTests = new Set() | ||
@@ -646,10 +649,4 @@ | ||
| // After finishing it might take a bit for the snapshot to be handled. | ||
| // This means that tests retried with DI are BREAKPOINT_HIT_GRACE_PERIOD_MS slower at least. | ||
| if (test._ddShouldWaitForHitProbe || test._retriedTest?._ddShouldWaitForHitProbe) { | ||
| await new Promise((resolve) => { | ||
| realSetTimeout(() => { | ||
| resolve() | ||
| }, BREAKPOINT_HIT_GRACE_PERIOD_MS) | ||
| }) | ||
| if (test._retriedTest?._ddShouldWaitForHitProbe) { | ||
| await waitForHitProbe() | ||
| } | ||
@@ -673,3 +670,3 @@ | ||
| function getOnHookEndHandler (config) { | ||
| function getOnHookEndHandler (config, finalAttemptHandlers) { | ||
| return function (hook) { | ||
@@ -687,12 +684,27 @@ const test = hook.ctx.currentTest | ||
| const testFinishInfo = getTestFinishInfo(test, status, config, ctx.err || test.err) | ||
| if (testFinishInfo.finalStatus !== undefined) { | ||
| test._ddIsFinalAttempt = true | ||
| const isFinalAttempt = testFinishInfo.finalStatus !== undefined | ||
| const publishTestFinish = () => { | ||
| testFinishCh.publish({ | ||
| status, | ||
| hasBeenRetried: isMochaRetry(test), | ||
| isLastRetry: getIsLastRetry(test), | ||
| ...testFinishInfo, | ||
| ...ctx.currentStore, | ||
| }) | ||
| if (isFinalAttempt) { | ||
| test._ddIsFinalAttempt = true | ||
| } | ||
| } | ||
| testFinishCh.publish({ | ||
| status, | ||
| hasBeenRetried: isMochaRetry(test), | ||
| isLastRetry: getIsLastRetry(test), | ||
| ...testFinishInfo, | ||
| ...ctx.currentStore, | ||
| }) | ||
| if (test._retriedTest?._ddShouldWaitForHitProbe) { | ||
| if (isFinalAttempt) { | ||
| finalAttemptHandlers?.onStart?.(test) | ||
| } | ||
| test._ddDeferredHookEnd = { | ||
| waitForHitProbePromise: waitForHitProbe(), | ||
| publishTestFinish, | ||
| onFinish: isFinalAttempt ? () => finalAttemptHandlers?.onFinish?.(test) : undefined, | ||
| } | ||
| return | ||
| } | ||
| publishTestFinish() | ||
| } | ||
@@ -704,2 +716,83 @@ } | ||
| function finishDeferredHookEnd (test) { | ||
| const deferredHookEnd = test?._ddDeferredHookEnd | ||
| if (!deferredHookEnd) return | ||
| const finish = () => { | ||
| try { | ||
| return deferredHookEnd.publishTestFinish() | ||
| } finally { | ||
| deferredHookEnd.onFinish?.() | ||
| } | ||
| } | ||
| delete test._ddDeferredHookEnd | ||
| if (!deferredHookEnd.waitForHitProbePromise) return finish() | ||
| return deferredHookEnd.waitForHitProbePromise.then( | ||
| finish, | ||
| finish | ||
| ) | ||
| } | ||
| /** | ||
| * Runs a Failed Test Replay hookUp callback after pending DI operations that must happen first. | ||
| * | ||
| * @param {(...args: unknown[]) => unknown} fn - Original hookUp completion callback. | ||
| * @param {object} test - Mocha test currently owning the hook. | ||
| * @param {Promise<void>|undefined} failedTestReplayPromise - Pending Failed Test Replay wait, if any. | ||
| * @param {unknown} hookThis - Callback receiver. | ||
| * @param {IArguments} args - Arguments passed by Mocha. | ||
| * @returns {unknown} | ||
| */ | ||
| function runFailedTestReplayHookUpCallback (fn, test, failedTestReplayPromise, hookThis, args) { | ||
| const continueAfterProbe = () => { | ||
| const deferredHookEndPromise = finishDeferredHookEnd(test) | ||
| if (deferredHookEndPromise) { | ||
| return deferredHookEndPromise.then(() => fn.apply(hookThis, args), () => fn.apply(hookThis, args)) | ||
| } | ||
| return fn.apply(hookThis, args) | ||
| } | ||
| if (failedTestReplayPromise) { | ||
| return failedTestReplayPromise.then(continueAfterProbe, continueAfterProbe) | ||
| } | ||
| return continueAfterProbe() | ||
| } | ||
| /** | ||
| * Wraps Mocha's hookUp completion callback so retries wait for DI before continuing. | ||
| * | ||
| * @param {(...args: unknown[]) => unknown} fn - Original hookUp completion callback. | ||
| * @param {object} test - Mocha test currently owning the hook. | ||
| * @param {Promise<void>|undefined} failedTestReplayPromise - Pending Failed Test Replay wait, if any. | ||
| * @returns {(...args: unknown[]) => unknown} | ||
| */ | ||
| function wrapFailedTestReplayHookUpCallback (fn, test, failedTestReplayPromise) { | ||
| return shimmer.wrapCallback(fn, fn => function () { | ||
| return runFailedTestReplayHookUpCallback(fn, test, failedTestReplayPromise, this, arguments) | ||
| }) | ||
| } | ||
| const patchedFailedTestReplayHookUp = new WeakSet() | ||
| function patchFailedTestReplayHookUp (Runner) { | ||
| if (patchedFailedTestReplayHookUp.has(Runner)) return | ||
| patchedFailedTestReplayHookUp.add(Runner) | ||
| shimmer.wrap(Runner.prototype, 'hookUp', hookUp => function (name, fn) { | ||
| const test = name === 'afterEach' && this.test | ||
| if (!test) { | ||
| return hookUp.apply(this, arguments) | ||
| } | ||
| const failedTestReplayPromise = test._ddFailedTestReplayPromise | ||
| if (failedTestReplayPromise) { | ||
| delete test._ddFailedTestReplayPromise | ||
| } | ||
| return hookUp.call(this, name, wrapFailedTestReplayHookUpCallback(fn, test, failedTestReplayPromise)) | ||
| }) | ||
| } | ||
| function getOnFailHandler (isMain, config) { | ||
@@ -775,2 +868,3 @@ return function (testOrHook, err) { | ||
| !test._ddIsEfdRetry | ||
| const promises = {} | ||
| testRetryCh.publish({ | ||
@@ -782,4 +876,13 @@ isFirstAttempt, | ||
| isAtrRetry, | ||
| promises, | ||
| ...ctx.currentStore, | ||
| }) | ||
| if (promises.setProbePromise && promises.finishTestPromise) { | ||
| test._ddFailedTestReplayPromise = Promise.all([ | ||
| promises.setProbePromise, | ||
| promises.finishTestPromise, | ||
| ]).then(() => {}) | ||
| } else if (promises.setProbePromise || promises.finishTestPromise) { | ||
| test._ddFailedTestReplayPromise = promises.setProbePromise || promises.finishTestPromise | ||
| } | ||
| } | ||
@@ -914,2 +1017,5 @@ const key = getTestToContextKey(test) | ||
| getOnHookEndHandler, | ||
| finishDeferredHookEnd, | ||
| wrapFailedTestReplayHookUpCallback, | ||
| patchFailedTestReplayHookUp, | ||
| getOnFailHandler, | ||
@@ -916,0 +1022,0 @@ getOnPendingHandler, |
@@ -16,2 +16,3 @@ 'use strict' | ||
| getRunTestsWrapper, | ||
| patchFailedTestReplayHookUp, | ||
| } = require('./utils') | ||
@@ -26,2 +27,6 @@ require('./common') | ||
| function isFailedTestReplayEnabled () { | ||
| return config.isTestDynamicInstrumentationEnabled && config.isDiEnabled | ||
| } | ||
| addHook({ | ||
@@ -65,2 +70,7 @@ name: 'mocha', | ||
| } | ||
| if (this.options._ddIsFailedTestReplayEnabled) { | ||
| config.isTestDynamicInstrumentationEnabled = true | ||
| config.isDiEnabled = true | ||
| delete this.options._ddIsFailedTestReplayEnabled | ||
| } | ||
| return run.apply(this, args) | ||
@@ -84,2 +94,5 @@ }) | ||
| } | ||
| if (isFailedTestReplayEnabled()) { | ||
| patchFailedTestReplayHookUp(Runner) | ||
| } | ||
| // We flush when the worker ends with its test file (a mocha instance in a worker runs a single test file) | ||
@@ -86,0 +99,0 @@ this.once('end', () => { |
@@ -31,2 +31,6 @@ 'use strict' | ||
| const bulkWriteStartCh = channel('apm:mongodb:bulkwrite:start') | ||
| const bulkWriteFinishCh = channel('apm:mongodb:bulkwrite:finish') | ||
| const bulkWriteErrorCh = channel('apm:mongodb:bulkwrite:error') | ||
| addHook({ name: 'mongodb', versions: ['>=3.3 <5', '5', '>=6'] }, mongodb => { | ||
@@ -59,3 +63,73 @@ for (const methodName of [...collectionMethodsWithFilter, ...collectionMethodsWithTwoFilters]) { | ||
| } | ||
| // `bulkWrite` fans out into separate `insert`/`update`/`delete` wire commands, so wrap | ||
| // it to open one parent span those per-type commands nest under as children. | ||
| if ('bulkWrite' in mongodb.Collection.prototype) { | ||
| shimmer.wrap(mongodb.Collection.prototype, 'bulkWrite', wrapBulkWrite) | ||
| } | ||
| return mongodb | ||
| }) | ||
| /** | ||
| * @param {Function} bulkWrite | ||
| * @returns {Function} | ||
| */ | ||
| function wrapBulkWrite (bulkWrite) { | ||
| return function (...args) { | ||
| /* istanbul ignore if: plugin stays subscribed for the whole suite; the disabled fast path is unreachable */ | ||
| if (!bulkWriteStartCh.hasSubscribers) { | ||
| return bulkWrite.apply(this, args) | ||
| } | ||
| const ctx = { ns: this.namespace } | ||
| return bulkWriteStartCh.runStores(ctx, () => { | ||
| // Pre-v5 drivers take a trailing callback and return `undefined`; v5+ ignore any | ||
| // extra argument and always return a promise. Wrap a trailing callback for the | ||
| // legacy path and finish on the returned promise when there is one, so both APIs are | ||
| // covered without sniffing the version. Pre-v5 also validates arguments synchronously, | ||
| // so guard the call to finish the span instead of leaking it on a throw. | ||
| const lastIndex = args.length - 1 | ||
| const callback = args[lastIndex] | ||
| if (typeof callback === 'function') { | ||
| args[lastIndex] = shimmer.wrapCallback(callback, callback => function (error) { | ||
| if (error) { | ||
| ctx.error = error | ||
| bulkWriteErrorCh.publish(ctx) | ||
| } | ||
| return bulkWriteFinishCh.runStores(ctx, callback, this, ...arguments) | ||
| }) | ||
| } | ||
| let result | ||
| try { | ||
| result = bulkWrite.apply(this, args) | ||
| } catch (error) { | ||
| finishBulkWriteError(ctx, error) | ||
| throw error | ||
| } | ||
| if (result !== undefined && typeof result.then === 'function') { | ||
| result.then(function (value) { | ||
| ctx.result = value | ||
| bulkWriteFinishCh.publish(ctx) | ||
| }, function (error) { | ||
| finishBulkWriteError(ctx, error) | ||
| }) | ||
| } | ||
| return result | ||
| }) | ||
| } | ||
| } | ||
| /** | ||
| * @param {{ error?: unknown }} ctx | ||
| * @param {unknown} error | ||
| */ | ||
| function finishBulkWriteError (ctx, error) { | ||
| ctx.error = error | ||
| bulkWriteErrorCh.publish(ctx) | ||
| bulkWriteFinishCh.publish(ctx) | ||
| } |
@@ -9,7 +9,6 @@ 'use strict' | ||
| const finishCh = channel('datadog:mongoose:model:filter:finish') | ||
| // Bound around the deferred query execution. A subscriber returns the store that | ||
| // stays active for the whole async scope that reaches the mongodb driver, so the | ||
| // nested driver query can see the parent's analysis marker without it leaking | ||
| // past the query. `runStores` enters the store only for that scope and restores | ||
| // the parent on its own. | ||
| // Bound around the deferred query execution. The `bindStore` transform returns a | ||
| // child store with the analysis marker set, covering the whole async scope that | ||
| // reaches the mongodb driver. `runStores` enters the child only for that scope | ||
| // and restores the parent on its own. | ||
| const execCh = channel('datadog:mongoose:model:filter:exec') | ||
@@ -16,0 +15,0 @@ // this channel is for wrapping the callback of exec methods and handling store context |
@@ -439,2 +439,3 @@ 'use strict' | ||
| codeOwnersEntries: testSessionConfiguration.codeOwnersEntries, | ||
| requestErrorTags: reporterState.state.requestErrorTags, | ||
| isTestFrameworkWorker: true, | ||
@@ -856,2 +857,3 @@ isVitestNoWorkerInitActive: true, | ||
| errors, | ||
| state, | ||
| status, | ||
@@ -872,2 +874,3 @@ task, | ||
| isTestFrameworkWorker: true, | ||
| requestErrorTags: state.requestErrorTags, | ||
| ...testSuiteStore, | ||
@@ -935,2 +938,3 @@ }) | ||
| isTestFrameworkWorker: true, | ||
| requestErrorTags: state.requestErrorTags, | ||
| } | ||
@@ -937,0 +941,0 @@ if (testProperties.isAttemptToFix) { |
@@ -67,2 +67,3 @@ 'use strict' | ||
| let coverageRootDir | ||
| let requestErrorTags = {} | ||
| let isSessionStarted = false | ||
@@ -394,2 +395,16 @@ let isVitestNoWorkerInitActive = false | ||
| /** | ||
| * Merges request error tags from a Test Optimization request response into the tags propagated by no-worker mode. | ||
| * | ||
| * @param {{ requestErrorTags?: Record<string, string> }|undefined} requestResponse - Request response. | ||
| */ | ||
| function mergeRequestErrorTags (requestResponse) { | ||
| if (requestResponse?.requestErrorTags) { | ||
| requestErrorTags = { | ||
| ...requestErrorTags, | ||
| ...requestResponse.requestErrorTags, | ||
| } | ||
| } | ||
| } | ||
| async function runMainProcessSetup (ctx, frameworkVersion, testSpecifications, shouldInstallNoWorkerInit) { | ||
@@ -419,5 +434,10 @@ if (!testSessionFinishCh.hasSubscribers) { | ||
| try { | ||
| const { err, libraryConfig } = await getChannelPromise(libraryConfigurationCh, frameworkVersion, { | ||
| const { | ||
| err, | ||
| libraryConfig, | ||
| requestErrorTags: receivedRequestErrorTags = {}, | ||
| } = await getChannelPromise(libraryConfigurationCh, frameworkVersion, { | ||
| isVitestNoWorkerInitActive: shouldInstallNoWorkerInit, | ||
| }) | ||
| requestErrorTags = receivedRequestErrorTags | ||
| if (err) { | ||
@@ -429,2 +449,3 @@ resetLibraryConfig() | ||
| } catch { | ||
| requestErrorTags = {} | ||
| resetLibraryConfig() | ||
@@ -443,2 +464,3 @@ } | ||
| codeOwnersEntries, | ||
| testEnvironmentMetadata, | ||
| } = testSessionConfiguration | ||
@@ -453,2 +475,3 @@ repositoryRoot = receivedRepositoryRoot || repositoryRoot | ||
| _ddCodeOwnersEntries: codeOwnersEntries, | ||
| _ddTestEnvironmentMetadata: testEnvironmentMetadata, | ||
| }, 'Could not send test session configuration to workers.') | ||
@@ -467,2 +490,4 @@ } | ||
| }) | ||
| mergeRequestErrorTags(knownTestsResponse) | ||
| mergeRequestErrorTags(testManagementTestsResponse) | ||
@@ -607,2 +632,3 @@ const flakyTestRetriesConfiguration = configureFlakyTestRetries(ctx, testSpecifications) | ||
| newTestsWithDynamicNames, | ||
| requestErrorTags, | ||
| testManagementAttemptToFixRetries, | ||
@@ -817,2 +843,3 @@ } | ||
| isTestManagementTestsEnabled, | ||
| requestErrorTags, | ||
| vitestPool, | ||
@@ -819,0 +846,0 @@ isVitestNoWorkerInitActive, |
@@ -11,2 +11,3 @@ 'use strict' | ||
| const testErrorCh = channel('ci:vitest:test:error') | ||
| const testDiWaitCh = channel('ci:vitest:test:di:wait') | ||
| const testSkipCh = channel('ci:vitest:test:skip') | ||
@@ -119,2 +120,3 @@ const testFnCh = channel('ci:vitest:test:fn') | ||
| _ddCodeOwnersEntries: codeOwnersEntries, | ||
| _ddTestEnvironmentMetadata: testEnvironmentMetadata, | ||
| } = globalThis.__vitest_worker__.providedContext | ||
@@ -141,2 +143,3 @@ | ||
| codeOwnersEntries, | ||
| testEnvironmentMetadata, | ||
| } | ||
@@ -164,2 +167,3 @@ } catch { | ||
| codeOwnersEntries: undefined, | ||
| testEnvironmentMetadata: undefined, | ||
| } | ||
@@ -227,2 +231,3 @@ } | ||
| testErrorCh, | ||
| testDiWaitCh, | ||
| testSkipCh, | ||
@@ -229,0 +234,0 @@ testFnCh, |
| 'use strict' | ||
| // Capture real timers at module load time, before any test can install fake timers. | ||
| const realSetTimeout = setTimeout | ||
| const { performance } = require('node:perf_hooks') | ||
@@ -21,2 +18,3 @@ | ||
| testErrorCh, | ||
| testDiWaitCh, | ||
| testSkipCh, | ||
@@ -36,4 +34,2 @@ testFnCh, | ||
| const BREAKPOINT_HIT_GRACE_PERIOD_MS = 400 | ||
| const taskToCtx = new WeakMap() | ||
@@ -44,2 +40,3 @@ const taskToTestProperties = new WeakMap() | ||
| const attemptToFixTaskToStatuses = new WeakMap() | ||
| const fileToHasConcurrentTests = new WeakMap() | ||
| const originalHookFns = new WeakMap() | ||
@@ -51,3 +48,5 @@ const newTasks = new WeakSet() | ||
| const attemptToFixTasks = new WeakSet() | ||
| const attemptToFixRetryTasks = new WeakSet() | ||
| const modifiedTasks = new WeakSet() | ||
| const efdRetryTasks = new WeakSet() | ||
| const efdDeterminedRetries = new WeakMap() | ||
@@ -59,4 +58,2 @@ const efdSlowAbortedTasks = new WeakSet() | ||
| const loggedAttemptToFixTests = new Set() | ||
| let isRetryReasonEfd = false | ||
| let isRetryReasonAttemptToFix = false | ||
| const switchedStatuses = new WeakSet() | ||
@@ -68,7 +65,5 @@ let vitestGetFn = null | ||
| function waitForHitProbe () { | ||
| return new Promise(resolve => { | ||
| realSetTimeout(() => { | ||
| resolve() | ||
| }, BREAKPOINT_HIT_GRACE_PERIOD_MS) | ||
| }) | ||
| const promises = {} | ||
| testDiWaitCh.publish({ promises }) | ||
| return promises.hitBreakpointPromise | ||
| } | ||
@@ -147,2 +142,66 @@ | ||
| /** | ||
| * Returns whether a Vitest task tree includes any concurrent task. | ||
| * | ||
| * @param {Array<{ type?: string, concurrent?: boolean, tasks?: object[] }>|undefined} tasks | ||
| * @returns {boolean} | ||
| */ | ||
| function hasConcurrentTask (tasks) { | ||
| if (!tasks) return false | ||
| for (const task of tasks) { | ||
| if (task.concurrent === true) return true | ||
| if (hasConcurrentTask(task.tasks)) return true | ||
| } | ||
| return false | ||
| } | ||
| /** | ||
| * Returns whether a Vitest file includes any concurrent test. | ||
| * | ||
| * @param {{ tasks?: object[] }} file | ||
| * @returns {boolean} | ||
| */ | ||
| function hasConcurrentTests (file) { | ||
| const cached = fileToHasConcurrentTests.get(file) | ||
| if (cached !== undefined) return cached | ||
| const hasConcurrent = hasConcurrentTask(file.tasks) | ||
| fileToHasConcurrentTests.set(file, hasConcurrent) | ||
| return hasConcurrent | ||
| } | ||
| /** | ||
| * Gets the task associated with a Vitest hook invocation. | ||
| * | ||
| * @param {unknown[]} args | ||
| * @param {object} fallbackTask | ||
| * @returns {object} | ||
| */ | ||
| function getTaskFromHookArgs (args, fallbackTask) { | ||
| return args[0]?.task || fallbackTask | ||
| } | ||
| /** | ||
| * Wraps a Vitest hook so it runs inside the span context for the current test. | ||
| * | ||
| * @param {'beforeEach'|'afterEach'} hookType | ||
| * @param {Function} fn | ||
| * @param {object} fallbackTask | ||
| * @returns {Function} | ||
| */ | ||
| function wrapSuiteHookFn (hookType, fn, fallbackTask) { | ||
| return shimmer.wrapFunction(fn, fn => function (...args) { | ||
| const task = getTaskFromHookArgs(args, fallbackTask) | ||
| const result = testFnCh.runStores(taskToCtx.get(task), () => fn.apply(this, args)) | ||
| if (hookType === 'beforeEach') { | ||
| return wrapBeforeEachCleanupResult(task, result) | ||
| } | ||
| return result | ||
| }) | ||
| } | ||
| function wrapVitestTestRunner (VitestTestRunner) { | ||
@@ -173,3 +232,5 @@ // `onBeforeRunTask` is run before any repetition or attempt is run | ||
| if (isAttemptToFix) { | ||
| isRetryReasonAttemptToFix = task.repeats !== testManagementAttemptToFixRetries | ||
| if (task.repeats !== testManagementAttemptToFixRetries) { | ||
| attemptToFixRetryTasks.add(task) | ||
| } | ||
| disableFrameworkRetries(task) | ||
@@ -194,3 +255,3 @@ task.repeats = testManagementAttemptToFixRetries | ||
| if (isEarlyFlakeDetectionEnabled) { | ||
| isRetryReasonEfd = true | ||
| efdRetryTasks.add(task) | ||
| disableFrameworkRetries(task) | ||
@@ -205,3 +266,3 @@ task.repeats = numRepeats | ||
| if (isEarlyFlakeDetectionEnabled && !modifiedTasks.has(task)) { | ||
| isRetryReasonEfd = true | ||
| efdRetryTasks.add(task) | ||
| disableFrameworkRetries(task) | ||
@@ -280,2 +341,3 @@ task.repeats = numRepeats | ||
| const { retry: numAttempt, repeats: numRepetition } = retryInfo | ||
| const isFailedTestReplayAllowed = !hasConcurrentTests(task.file) | ||
| const isEfdManagedTask = isEarlyFlakeDetectionEnabled && taskToStatuses.has(task) && !attemptToFixTasks.has(task) | ||
@@ -317,3 +379,3 @@ | ||
| if (numAttempt > 0) { | ||
| const shouldWaitForHitProbe = isDiEnabled && numAttempt > 1 | ||
| const shouldWaitForHitProbe = isDiEnabled && isFailedTestReplayAllowed && numAttempt > 1 | ||
| if (shouldWaitForHitProbe) { | ||
@@ -324,3 +386,3 @@ await waitForHitProbe() | ||
| const promises = {} | ||
| const shouldSetProbe = isDiEnabled && numAttempt === 1 | ||
| const shouldSetProbe = isDiEnabled && isFailedTestReplayAllowed && numAttempt === 1 | ||
| const ctx = taskToCtx.get(task) | ||
@@ -332,2 +394,3 @@ const testError = getCurrentAttemptTestError(task, task.result?.errors) | ||
| shouldSetProbe, | ||
| shouldWaitForHitProbe, | ||
| promises, | ||
@@ -394,4 +457,4 @@ ...ctx.currentStore, | ||
| isFlakyTestRetriesEnabledForTask(providedContext, task) && | ||
| !isRetryReasonAttemptToFix && | ||
| !isRetryReasonEfd | ||
| !attemptToFixRetryTasks.has(task) && | ||
| !efdRetryTasks.has(task) | ||
@@ -402,7 +465,7 @@ const ctx = { | ||
| isRetry: numAttempt > 0 || numRepetition > 0, | ||
| isRetryReasonEfd, | ||
| isRetryReasonAttemptToFix: isRetryReasonAttemptToFix && numRepetition > 0, | ||
| isRetryReasonEfd: efdRetryTasks.has(task), | ||
| isRetryReasonAttemptToFix: attemptToFixRetryTasks.has(task) && numRepetition > 0, | ||
| isNew, | ||
| hasDynamicName: dynamicNameTasks.has(task), | ||
| mightHitProbe: isDiEnabled && numAttempt > 0, | ||
| mightHitProbe: isDiEnabled && isFailedTestReplayAllowed && numAttempt > 0, | ||
| isAttemptToFix: attemptToFixTasks.has(task), | ||
@@ -448,13 +511,5 @@ isDisabled: disabledTasks.has(task), | ||
| const currentFn = hookArray[i] | ||
| const originalFn = originalHookFns.get(currentFn) || currentFn | ||
| const wrappedFn = shimmer.wrapFunction(originalFn, fn => function (...args) { | ||
| const result = testFnCh.runStores(taskToCtx.get(task), () => fn.apply(this, args)) | ||
| if (hookType === 'beforeEach') { | ||
| return wrapBeforeEachCleanupResult(task, result) | ||
| } | ||
| return result | ||
| }) | ||
| originalHookFns.set(wrappedFn, originalFn) | ||
| if (originalHookFns.has(currentFn)) continue | ||
| const wrappedFn = wrapSuiteHookFn(hookType, currentFn, task) | ||
| originalHookFns.set(wrappedFn, currentFn) | ||
| hookArray[i] = wrappedFn | ||
@@ -488,2 +543,3 @@ } | ||
| const { isDiEnabled } = getProvidedContext() | ||
| const isFailedTestReplayAllowed = !hasConcurrentTests(task.file) | ||
@@ -496,3 +552,3 @@ if (efdSkippedRetryResults.has(task)) { | ||
| if (isDiEnabled && retryInfo.retry > 1) { | ||
| if (isDiEnabled && isFailedTestReplayAllowed && retryInfo.retry > 1) { | ||
| await waitForHitProbe() | ||
@@ -623,2 +679,3 @@ } | ||
| codeOwnersEntries: providedContext.codeOwnersEntries, | ||
| testEnvironmentMetadata: providedContext.testEnvironmentMetadata, | ||
| } | ||
@@ -634,2 +691,3 @@ testSuiteStartCh.runStores(testSuiteCtx, () => {}) | ||
| const testTasks = getTypeTasks(startTestsResponse[0].tasks) | ||
| const testEventPromises = [] | ||
@@ -663,2 +721,3 @@ // Only one test task per test, even if there are retries | ||
| !attemptToFixTasks.has(task) && (disabledTasks.has(task) || quarantinedTasks.has(task)) | ||
| const promises = {} | ||
| testPassCh.publish({ | ||
@@ -668,4 +727,8 @@ task, | ||
| earlyFlakeAbortReason: efdSlowAbortedTasks.has(task) ? 'slow' : undefined, | ||
| promises, | ||
| ...testCtx.currentStore, | ||
| }) | ||
| if (promises.hitBreakpointPromise) { | ||
| testEventPromises.push(promises.hitBreakpointPromise) | ||
| } | ||
| } | ||
@@ -710,2 +773,3 @@ } else if (state === 'fail' || isSwitchedStatus) { | ||
| const isRetry = task.result?.retryCount > 0 | ||
| const promises = {} | ||
| // `duration` is the duration of all the retries, so it can't be used if there are retries | ||
@@ -735,4 +799,8 @@ | ||
| earlyFlakeAbortReason: efdSlowAbortedTasks.has(task) ? 'slow' : undefined, | ||
| promises, | ||
| ...testCtx.currentStore, | ||
| }) | ||
| if (promises.hitBreakpointPromise) { | ||
| testEventPromises.push(promises.hitBreakpointPromise) | ||
| } | ||
| } | ||
@@ -753,2 +821,4 @@ if (errors?.length) { | ||
| await Promise.all(testEventPromises) | ||
| const testSuiteResult = startTestsResponse[0].result | ||
@@ -755,0 +825,0 @@ |
@@ -8,3 +8,3 @@ 'use strict' | ||
| class VercelAIPlugin extends CompositePlugin { | ||
| static get id () { return 'ai' } | ||
| static id = 'ai' | ||
| static get plugins () { | ||
@@ -11,0 +11,0 @@ return { |
| 'use strict' | ||
| const CompositePlugin = require('../../dd-trace/src/plugins/composite') | ||
| const TracingPlugin = require('../../dd-trace/src/plugins/tracing') | ||
| const { getModelProvider } = require('./utils') | ||
| const { getModelProvider, parseModelProvider } = require('./utils') | ||
| class VercelAITracingPlugin extends TracingPlugin { | ||
| static id = 'ai' | ||
| class DdTelemetryPlugin extends TracingPlugin { | ||
| static id = 'ai_tracing_dd_telemetry' | ||
| static prefix = 'tracing:dd-trace:vercel-ai' | ||
@@ -33,2 +34,64 @@ | ||
| class VercelAiTelemetryPlugin extends TracingPlugin { | ||
| static id = 'ai_tracing_vercel_telemetry' | ||
| static prefix = 'tracing:ai:telemetry' | ||
| #streamedCalls = new Set() | ||
| constructor () { | ||
| super(...arguments) | ||
| this.addSub('dd-trace:vercel-ai:chunk', ({ ctx, chunk, done }) => { | ||
| ctx.streamConsumed = done | ||
| }) | ||
| } | ||
| bindStart (ctx) { | ||
| const { type: name, event } = ctx | ||
| const model = event.modelId | ||
| const modelProvider = parseModelProvider(event.provider, model) | ||
| let isStream = this.#streamedCalls.has(event.callId) | ||
| if (name.includes('stream')) { | ||
| this.#streamedCalls.add(event.callId) | ||
| isStream = true | ||
| ctx.streamConsumed = false | ||
| } | ||
| ctx.isStream = isStream | ||
| this.startSpan(name, { | ||
| meta: { | ||
| 'resource.name': event.functionId ?? name, | ||
| 'ai.request.model': model, | ||
| 'ai.request.model_provider': modelProvider, | ||
| }, | ||
| }, ctx) | ||
| return ctx.currentStore | ||
| } | ||
| asyncEnd (ctx) { | ||
| // check if isStreamed and stream resolved | ||
| // this event will fire multiple times for the same channel | ||
| if (ctx.isStream && ctx.result?.stream && !ctx.streamConsumed) return | ||
| if (ctx.type?.includes('stream')) { | ||
| this.#streamedCalls.delete(ctx.event?.callId) | ||
| } | ||
| const span = ctx.currentStore?.span | ||
| span?.finish() | ||
| } | ||
| } | ||
| class VercelAITracingPlugin extends CompositePlugin { | ||
| static id = 'ai_tracing' | ||
| static plugins = { | ||
| dd: DdTelemetryPlugin, | ||
| ai: VercelAiTelemetryPlugin, | ||
| } | ||
| } | ||
| module.exports = VercelAITracingPlugin |
| 'use strict' | ||
| const { parseModelId } = require('../../datadog-plugin-aws-sdk/src/services/bedrockruntime/utils') | ||
| const { parseModelId: parseBedrockModelId } = require('../../datadog-plugin-aws-sdk/src/services/bedrockruntime/utils') | ||
@@ -14,8 +14,20 @@ /** | ||
| const modelProviderTag = tags['ai.model.provider'] | ||
| const providerParts = modelProviderTag?.split('.') | ||
| const modelId = tags['ai.model.id'] | ||
| return parseModelProvider(modelProviderTag, modelId) | ||
| } | ||
| /** | ||
| * Parse the model provider from the raw provider string. | ||
| * | ||
| * @param {string} rawProvider | ||
| * @param {string} modelId | ||
| * @returns {string} | ||
| */ | ||
| function parseModelProvider (rawProvider, modelId) { | ||
| const providerParts = rawProvider?.split('.') | ||
| const provider = providerParts?.[0] | ||
| if (provider === 'amazon-bedrock') { | ||
| const modelId = tags['ai.model.id'] | ||
| const model = modelId && parseModelId(modelId) | ||
| const model = modelId && parseBedrockModelId(modelId) | ||
| return model?.modelProvider ?? provider | ||
@@ -29,2 +41,3 @@ } | ||
| getModelProvider, | ||
| parseModelProvider, | ||
| } |
@@ -53,2 +53,3 @@ 'use strict' | ||
| resource, | ||
| type: this.constructor.type, | ||
| kind: this.constructor.kind, | ||
@@ -55,0 +56,0 @@ meta, |
@@ -55,2 +55,3 @@ 'use strict' | ||
| resource: handler?.name, | ||
| type: this.constructor.type, | ||
| kind: this.constructor.kind, | ||
@@ -57,0 +58,0 @@ meta, |
@@ -100,7 +100,18 @@ 'use strict' | ||
| asyncEnd (ctx) { | ||
| const { result } = ctx | ||
| const { result, error } = ctx | ||
| let exitCode | ||
| if (result !== null && typeof result === 'object') { | ||
| // util.promisify(execFile) resolves with a { stdout, stderr } object on | ||
| // success, where the exit code is 0. | ||
| exitCode = result.status ?? 0 | ||
| } else if (result === undefined && error !== undefined) { | ||
| exitCode = error.status ?? error.code ?? 0 | ||
| } else { | ||
| exitCode = result | ||
| } | ||
| const span = ctx.currentStore?.span || this.activeSpan | ||
| span?.setTag('cmd.exit_code', `${result}`) | ||
| span?.setTag('cmd.exit_code', `${exitCode}`) | ||
| span?.finish() | ||
@@ -107,0 +118,0 @@ |
| 'use strict' | ||
| // Capture real timers at module load time, before any test can install fake timers. | ||
| const realDateNow = Date.now.bind(Date) | ||
| const realSetTimeout = setTimeout | ||
| const CiPlugin = require('../../dd-trace/src/plugins/ci_plugin') | ||
@@ -57,5 +53,2 @@ const { storage } = require('../../datadog-core') | ||
| const BREAKPOINT_HIT_GRACE_PERIOD_MS = 200 | ||
| const BREAKPOINT_SET_GRACE_PERIOD_MS = 400 | ||
| const isCucumberWorker = !!getEnvironmentVariable('CUCUMBER_WORKER_ID') | ||
@@ -252,7 +245,4 @@ | ||
| this.activeTestSpan = span | ||
| // Time we give the breakpoint to be hit | ||
| if (promises && this.runningTestProbe) { | ||
| promises.hitBreakpointPromise = new Promise((resolve) => { | ||
| realSetTimeout(resolve, BREAKPOINT_HIT_GRACE_PERIOD_MS) | ||
| }) | ||
| promises.hitBreakpointPromise = this.waitForDiBreakpointHits() | ||
| } | ||
@@ -263,3 +253,3 @@ | ||
| this.addSub('ci:cucumber:test:retry', ({ span, isFirstAttempt, error, isAtrRetry }) => { | ||
| this.addSub('ci:cucumber:test:retry', ({ span, isFirstAttempt, error, isAtrRetry, promises, canWaitForDi }) => { | ||
| if (!isFirstAttempt) { | ||
@@ -274,14 +264,14 @@ span.setTag(TEST_IS_RETRY, 'true') | ||
| span.setTag('error', error) | ||
| if (isFirstAttempt && this.di && error && this.libraryConfig?.isDiEnabled) { | ||
| const probeInformation = this.addDiProbe(error) | ||
| if (probeInformation) { | ||
| const { file, line, stackIndex } = probeInformation | ||
| this.runningTestProbe = { file, line } | ||
| this.testErrorStackIndex = stackIndex | ||
| const waitUntil = realDateNow() + BREAKPOINT_SET_GRACE_PERIOD_MS | ||
| while (realDateNow() < waitUntil) { | ||
| // TODO: To avoid a race condition, we should wait until `probeInformation.setProbePromise` has resolved. | ||
| // However, Cucumber doesn't have a mechanism for waiting asyncrounously here, so for now, we'll have to | ||
| // fall back to a fixed syncronous delay. | ||
| if (canWaitForDi !== false && promises && this.di && error && this.libraryConfig?.isDiEnabled) { | ||
| if (isFirstAttempt) { | ||
| const probeInformation = this.addDiProbe(error) | ||
| if (probeInformation) { | ||
| const { file, line, stackIndex, setProbePromise } = probeInformation | ||
| this.runningTestProbe = { file, line } | ||
| this.testErrorStackIndex = stackIndex | ||
| this.prepareDiBreakpointHitWait() | ||
| promises.setProbePromise = this.waitForDiOperation(setProbePromise) | ||
| } | ||
| } else if (this.runningTestProbe) { | ||
| this.prepareDiBreakpointHitWait() | ||
| } | ||
@@ -417,2 +407,3 @@ } | ||
| this.activeTestSpan = null | ||
| this.cancelDiBreakpointHitWait() | ||
| if (this.runningTestProbe) { | ||
@@ -419,0 +410,0 @@ this.removeDiProbe(this.runningTestProbe) |
@@ -21,3 +21,10 @@ 'use strict' | ||
| const { on, config, userAfterSpecHandlers, userAfterRunHandlers, cleanupWrapper } = payload | ||
| const { | ||
| on, | ||
| config, | ||
| userAfterSpecHandlers, | ||
| userAfterRunHandlers, | ||
| userAfterScreenshotHandlers, | ||
| cleanupWrapper, | ||
| } = payload | ||
@@ -38,6 +45,43 @@ const registerAfterRunWithCleanup = (afterRunHandler) => { | ||
| const cypressPlugin = require('./cypress-plugin') | ||
| const datadogAfterScreenshotHandler = cypressPlugin.getAfterScreenshotHandler() | ||
| // Cypress keeps a single after:screenshot handler, so registering the | ||
| // plugin's would drop any user handler (e.g. one that moves/renames a | ||
| // screenshot). Chain the user handler(s) first, threading the returned | ||
| // { path, size, dimensions } forward — Cypress uses the final path for | ||
| // downstream steps — then run the plugin's afterScreenshot on those | ||
| // details and propagate the value back to Cypress. The user handler runs | ||
| // regardless of whether screenshot upload is enabled. | ||
| const registerAfterScreenshot = (afterScreenshotHandler) => { | ||
| const filteredUserAfterScreenshotHandlers = userAfterScreenshotHandlers.filter( | ||
| handler => handler !== afterScreenshotHandler | ||
| ) | ||
| if (filteredUserAfterScreenshotHandlers.length === 0) { | ||
| if (afterScreenshotHandler) on('after:screenshot', afterScreenshotHandler) | ||
| return | ||
| } | ||
| on('after:screenshot', (details) => { | ||
| const chain = filteredUserAfterScreenshotHandlers.reduce( | ||
| (p, h) => p.then((latestDetails) => Promise.resolve(h(latestDetails)).then( | ||
| // Merge the user handler's (possibly partial) return into the running details | ||
| // rather than replacing them, so Cypress metadata such as `testFailure` and | ||
| // `takenAt` survives a handler that only returns { path, size, dimensions }. | ||
| (returned) => (returned == null ? latestDetails : { ...latestDetails, ...returned }) | ||
| )), | ||
| Promise.resolve(details) | ||
| ) | ||
| return chain.then((finalDetails) => { | ||
| if (afterScreenshotHandler) afterScreenshotHandler(finalDetails) | ||
| return finalDetails | ||
| }) | ||
| }) | ||
| } | ||
| if (cypressPlugin._isInit) { | ||
| // Already initialized by manual plugin call — just chain user handlers | ||
| // Already initialized by manual plugin call — just chain user handlers. | ||
| // Pass the plugin's afterScreenshot so chaining a user handler doesn't drop the upload | ||
| // (the chained registration replaces the one plugin.js set, so it must include it). | ||
| for (const h of userAfterSpecHandlers) on('after:spec', h) | ||
| registerAfterScreenshot(datadogAfterScreenshotHandler) | ||
| registerAfterRunWithCleanup() | ||
@@ -49,2 +93,3 @@ payload.registered = true | ||
| on('before:run', cypressPlugin.beforeRun.bind(cypressPlugin)) | ||
| registerAfterScreenshot(datadogAfterScreenshotHandler) | ||
@@ -51,0 +96,0 @@ on('after:spec', (spec, results) => { |
@@ -43,2 +43,3 @@ 'use strict' | ||
| on('before:run', cypressPlugin.beforeRun.bind(cypressPlugin)) | ||
| on('after:screenshot', cypressPlugin.getAfterScreenshotHandler()) | ||
| on('after:spec', cypressPlugin.afterSpec.bind(cypressPlugin)) | ||
@@ -45,0 +46,0 @@ on('after:run', cypressPlugin.afterRun.bind(cypressPlugin)) |
@@ -244,3 +244,3 @@ 'use strict' | ||
| for (const err of res.errors) { | ||
| extractErrorIntoSpanEvent(this._tracerConfig, span, err) | ||
| extractErrorIntoSpanEvent(this.config, span, err) | ||
| } | ||
@@ -275,2 +275,5 @@ } | ||
| // ctx form: startSpan sets field.currentStore = { ...activeStore, span } | ||
| // without entering it. Only the field's first resolver call runs in that | ||
| // store (isFirst check in wrapResolve); siblings use field.parentStore. | ||
| const span = this.startSpan('graphql.resolve', { | ||
@@ -288,4 +291,6 @@ service: this.config.service, | ||
| }, | ||
| }, false) | ||
| }, field) | ||
| field.span = span | ||
| if (fieldNode && this.config.variables && fieldNode.arguments) { | ||
@@ -394,2 +399,6 @@ const variables = this.config.variables(variableValues) | ||
| span: null, | ||
| // Set by startResolveSpan; currentStore is used by the first resolver | ||
| // call only, siblings use parentStore (see the isFirst check below). | ||
| parentStore: null, | ||
| currentStore: null, | ||
| } | ||
@@ -401,5 +410,8 @@ rootCtx.fields.set(fieldKey, field) | ||
| // publish per resolver call, even when the span is collapsed) and route | ||
| // through callInAsyncScope so the abort signal stops them mid-flight. | ||
| // through callInAsyncScope so the abort signal stops them mid-flight. They | ||
| // run in the parent store, not field.currentStore: the first sibling's | ||
| // synchronous resolver already finished the shared graphql.resolve span, so | ||
| // re-entering its store would parent user spans to a closed span. | ||
| if (!isFirst) { | ||
| return callInAsyncScope(resolve, this, arguments, rootCtx.abortController, (err) => { | ||
| return callInAsyncScope(resolve, this, arguments, rootCtx.abortController, field.parentStore, (err) => { | ||
| if (updateFieldCh.hasSubscribers) { | ||
@@ -414,5 +426,4 @@ updateFieldCh.publish({ rootCtx, field, error: err, pathString: field.pathString }) | ||
| const span = rootCtx.plugin.startResolveSpan(field, rootCtx, executeSpan, startTime) | ||
| field.span = span | ||
| return callInAsyncScope(resolve, this, arguments, rootCtx.abortController, (err, res) => { | ||
| return callInAsyncScope(resolve, this, arguments, rootCtx.abortController, field.currentStore, (err, res) => { | ||
| const endTime = executeSpan._getTime() | ||
@@ -455,5 +466,5 @@ rootCtx.plugin.finishResolveSpan(span, field, err, res, endTime || startTime) | ||
| function callInAsyncScope (fn, thisArg, args, abortController, cb) { | ||
| cb ??= () => {} | ||
| // Runs the resolver inside `store`, including any code after an internal | ||
| // `await`. A `.then()` the caller attaches afterward runs outside `store`. | ||
| function callInAsyncScope (fn, thisArg, args, abortController, store, cb) { | ||
| if (abortController?.signal.aborted) { | ||
@@ -465,3 +476,3 @@ cb(null, null) | ||
| try { | ||
| const result = fn.apply(thisArg, args) | ||
| const result = legacyStorage.run(store, () => fn.apply(thisArg, args)) | ||
| if (typeof result?.then === 'function') { | ||
@@ -468,0 +479,0 @@ return result.then( |
@@ -33,2 +33,9 @@ 'use strict' | ||
| // config validator helpers | ||
| // | ||
| // `collapse`, `depth`, `variables`, and `errorExtensions` arrive pre-merged on | ||
| // `config`: plugin_manager seeds the `DD_TRACE_GRAPHQL_*` env values (already | ||
| // parsed to their declared type) as the base and a programmatic | ||
| // `tracer.use('graphql', …)` value overrides them. So these helpers only shape | ||
| // the merged value — coerce, validate, and turn `variables` into a filter — and | ||
| // never read the environment themselves. | ||
@@ -45,2 +52,3 @@ function validateConfig (config) { | ||
| countListIndices: DD_MAJOR < 6 && collapse, | ||
| errorExtensions: getErrorExtensions(config), | ||
| hooks: getHooks(config), | ||
@@ -63,3 +71,3 @@ } | ||
| } else if (Array.isArray(config.variables)) { | ||
| return variables => pick(variables, config.variables) | ||
| return config.variables.length > 0 ? variables => pick(variables, config.variables) : null | ||
| } else if (config.hasOwnProperty('variables')) { | ||
@@ -71,2 +79,10 @@ log.error('Expected `variables` to be an array or function.') | ||
| function getErrorExtensions (config) { | ||
| if (Array.isArray(config.errorExtensions)) { | ||
| return config.errorExtensions.length > 0 ? config.errorExtensions : undefined | ||
| } else if (config.hasOwnProperty('errorExtensions')) { | ||
| log.error('Expected `errorExtensions` to be an array.') | ||
| } | ||
| } | ||
| const noop = () => {} | ||
@@ -73,0 +89,0 @@ const noopHooks = { execute: noop, parse: noop, validate: noop, resolve: undefined } |
| 'use strict' | ||
| /** | ||
| * @param {{ errorExtensions?: string[] }} config Resolved plugin config; `errorExtensions` lists the | ||
| * GraphQL error `extensions` keys to copy onto the span event. | ||
| * @param {import('../../dd-trace/src/opentracing/span')} span | ||
| * @param {{ name?: string, message?: string, stack?: string, locations?: Array<{ line: number, column: number }>, | ||
| * path?: Array<string|number>, extensions?: Record<string, unknown> }} exc | ||
| */ | ||
| function extractErrorIntoSpanEvent (config, span, exc) { | ||
@@ -32,4 +39,4 @@ const attributes = {} | ||
| if (config.DD_TRACE_GRAPHQL_ERROR_EXTENSIONS) { | ||
| for (const ext of config.DD_TRACE_GRAPHQL_ERROR_EXTENSIONS) { | ||
| if (config.errorExtensions) { | ||
| for (const ext of config.errorExtensions) { | ||
| if (exc.extensions?.[ext]) { | ||
@@ -36,0 +43,0 @@ const value = exc.extensions[ext] |
@@ -41,3 +41,3 @@ 'use strict' | ||
| for (const err of errors) { | ||
| extractErrorIntoSpanEvent(this._tracerConfig, span, err) | ||
| extractErrorIntoSpanEvent(this.config, span, err) | ||
| } | ||
@@ -44,0 +44,0 @@ } |
@@ -64,11 +64,2 @@ 'use strict' | ||
| function withTimeout (promise, timeoutMs) { | ||
| return new Promise(resolve => { | ||
| realSetTimeout(resolve, timeoutMs) | ||
| // Also resolve if the original promise resolves | ||
| promise.then(resolve) | ||
| }) | ||
| } | ||
| class JestPlugin extends CiPlugin { | ||
@@ -455,2 +446,3 @@ static id = 'jest' | ||
| earlyFlakeAbortReason, | ||
| promises, | ||
| }) => { | ||
@@ -486,8 +478,29 @@ span.setTag(TEST_STATUS, status) | ||
| span.finish() | ||
| finishAllTraceSpans(span) | ||
| this.activeTestSpan = null | ||
| const finish = () => { | ||
| span.finish() | ||
| finishAllTraceSpans(span) | ||
| this.activeTestSpan = null | ||
| } | ||
| if (finalStatus) { | ||
| if (promises?.hitBreakpointPromise) { | ||
| finish() | ||
| this.cancelDiBreakpointHitWait() | ||
| return | ||
| } | ||
| if (promises && this.diBreakpointHitPromise) { | ||
| promises.hitBreakpointPromise = this.waitForPreparedDiBreakpointHit().then(finish) | ||
| return | ||
| } | ||
| finish() | ||
| this.cancelDiBreakpointHitWait() | ||
| return | ||
| } | ||
| finish() | ||
| if (status === 'fail' && promises?.hitBreakpointPromise) { | ||
| this.prepareDiBreakpointHitWait() | ||
| } | ||
| }) | ||
| this.addSub('ci:jest:test:err', ({ span, error, shouldSetProbe, promises }) => { | ||
| this.addSub('ci:jest:test:err', ({ span, error, shouldSetProbe, shouldWaitForHitProbe, promises }) => { | ||
| if (error && span) { | ||
@@ -500,4 +513,7 @@ span.setTag(TEST_STATUS, 'fail') | ||
| const { setProbePromise } = probeInformation | ||
| promises.isProbeReady = withTimeout(setProbePromise, 2000) | ||
| this.prepareDiBreakpointHitWait() | ||
| promises.isProbeReady = this.waitForDiOperation(setProbePromise) | ||
| } | ||
| } else if (shouldWaitForHitProbe && this.di) { | ||
| promises.hitBreakpointPromise = this.waitForDiBreakpointHits() | ||
| } | ||
@@ -504,0 +520,0 @@ } |
| 'use strict' | ||
| // Capture real Date.now at module load time, before any test can install fake timers. | ||
| const realDateNow = Date.now.bind(Date) | ||
| const CiPlugin = require('../../dd-trace/src/plugins/ci_plugin') | ||
@@ -55,4 +52,2 @@ const { storage } = require('../../datadog-core') | ||
| const BREAKPOINT_SET_GRACE_PERIOD_MS = 200 | ||
| class MochaPlugin extends CiPlugin { | ||
@@ -267,2 +262,3 @@ static id = 'mocha' | ||
| this.activeTestSpan = null | ||
| this.cancelDiBreakpointHitWait() | ||
| if (this.di && this.libraryConfig?.isDiEnabled && this.runningTestProbe && isLastRetry) { | ||
@@ -312,4 +308,20 @@ this.removeDiProbe(this.runningTestProbe) | ||
| this.addSub('ci:mocha:test:retry', ({ span, isFirstAttempt, willBeRetried, err, test, isAtrRetry }) => { | ||
| this.addSub('ci:mocha:test:retry', ({ | ||
| span, | ||
| isFirstAttempt, | ||
| willBeRetried, | ||
| err, | ||
| test, | ||
| isAtrRetry, | ||
| promises, | ||
| }) => { | ||
| if (span) { | ||
| const finishSpan = () => { | ||
| span.finish() | ||
| finishAllTraceSpans(span) | ||
| if (this.activeTestSpan === span) { | ||
| this.activeTestSpan = null | ||
| } | ||
| } | ||
| span.setTag(TEST_STATUS, 'fail') | ||
@@ -336,11 +348,9 @@ if (!isFirstAttempt) { | ||
| if (probeInformation) { | ||
| const { file, line, stackIndex } = probeInformation | ||
| const { file, line, stackIndex, setProbePromise } = probeInformation | ||
| this.runningTestProbe = { file, line } | ||
| this.testErrorStackIndex = stackIndex | ||
| test._ddShouldWaitForHitProbe = true | ||
| const waitUntil = realDateNow() + BREAKPOINT_SET_GRACE_PERIOD_MS | ||
| while (realDateNow() < waitUntil) { | ||
| // TODO: To avoid a race condition, we should wait until `probeInformation.setProbePromise` has resolved. | ||
| // However, Mocha doesn't have a mechanism for waiting asyncrounously here, so for now, we'll have to | ||
| // fall back to a fixed syncronous delay. | ||
| this.prepareDiBreakpointHitWait() | ||
| if (promises) { | ||
| promises.setProbePromise = this.waitForDiOperation(setProbePromise) | ||
| } | ||
@@ -350,7 +360,22 @@ } | ||
| span.finish() | ||
| finishAllTraceSpans(span) | ||
| if (!isFirstAttempt && | ||
| willBeRetried && | ||
| this.di && | ||
| this.libraryConfig?.isDiEnabled && | ||
| this.runningTestProbe && | ||
| promises) { | ||
| promises.finishTestPromise = this.waitForInFlightDiBreakpointHits().then(finishSpan, finishSpan) | ||
| return | ||
| } | ||
| finishSpan() | ||
| } | ||
| }) | ||
| this.addSub('ci:mocha:test:di:wait', ({ promises }) => { | ||
| if (this.di) { | ||
| promises.hitBreakpointPromise = this.waitForDiBreakpointHits() | ||
| } | ||
| }) | ||
| this.addSub('ci:mocha:test:parameterize', ({ title, params }) => { | ||
@@ -357,0 +382,0 @@ this._testTitleToParams[title] = params |
| 'use strict' | ||
| const { isMap, isRegExp } = require('node:util').types | ||
| const CompositePlugin = require('../../dd-trace/src/plugins/composite') | ||
| const MongodbCoreBulkWritePlugin = require('./bulk-write') | ||
| const MongodbCoreQueryPlugin = require('./query') | ||
| const DatabasePlugin = require('../../dd-trace/src/plugins/database') | ||
| class MongodbCorePlugin extends DatabasePlugin { | ||
| class MongodbCorePlugin extends CompositePlugin { | ||
| static id = 'mongodb-core' | ||
| static component = 'mongodb' | ||
| // avoid using db.name for peer.service since it includes the collection name | ||
| // should be removed if one day this will be fixed | ||
| /** | ||
| * @override | ||
| */ | ||
| static peerServicePrecursors = [] | ||
| /** | ||
| * @override | ||
| */ | ||
| configure (config) { | ||
| super.configure(config) | ||
| this.config.heartbeatEnabled = config.heartbeatEnabled ?? | ||
| this._tracerConfig.DD_TRACE_MONGODB_HEARTBEAT_ENABLED | ||
| this.config.obfuscateQuery = normaliseObfuscateQuery( | ||
| config.obfuscateQuery ?? this._tracerConfig.DD_TRACE_MONGODB_OBFUSCATE_QUERY | ||
| ) | ||
| } | ||
| bindStart (ctx) { | ||
| const { ns, ops, options = {}, name } = ctx | ||
| // heartbeat commands can be disabled if this.config.heartbeatEnabled is false | ||
| if (!this.config.heartbeatEnabled && isHeartbeat(ops, this.config)) { | ||
| return | ||
| static get plugins () { | ||
| return { | ||
| query: MongodbCoreQueryPlugin, | ||
| bulkWrite: MongodbCoreBulkWritePlugin, | ||
| } | ||
| const query = getQuery(ops, this.config.obfuscateQuery) | ||
| const resource = truncate(getResource(this, ns, query, name)) | ||
| const serviceResult = this.serviceName({ pluginConfig: this.config }) | ||
| const span = this.startSpan(this.operationName(), { | ||
| service: serviceResult, | ||
| resource, | ||
| type: 'mongodb', | ||
| kind: 'client', | ||
| meta: { | ||
| // this is not technically correct since it includes the collection but we changing will break customer stuff | ||
| 'db.name': ns, | ||
| 'mongodb.query': query, | ||
| 'out.host': options.host, | ||
| 'out.port': options.port, | ||
| }, | ||
| }, ctx) | ||
| const comment = this.injectDbmComment(span, ops.comment, serviceResult.name) | ||
| if (comment) { | ||
| ops.comment = comment | ||
| } | ||
| return ctx.currentStore | ||
| } | ||
| /** | ||
| * @override | ||
| */ | ||
| getPeerService (tags) { | ||
| let ns = tags['db.name'] | ||
| if (ns && tags['peer.service'] === undefined) { | ||
| const dotIndex = ns.indexOf('.') | ||
| if (dotIndex !== -1) { | ||
| ns = ns.slice(0, dotIndex) | ||
| } | ||
| // the mongo ns is either dbName either dbName.collection. So we keep the first part | ||
| tags['peer.service'] = ns | ||
| } | ||
| return super.getPeerService(tags) | ||
| } | ||
| injectDbmComment (span, comment, serviceName) { | ||
| const dbmTraceComment = this.createDbmComment(span, serviceName) | ||
| if (!dbmTraceComment) { | ||
| return comment | ||
| } | ||
| if (comment) { | ||
| // if the command already has a comment, append the dbm trace comment | ||
| if (typeof comment === 'string') { | ||
| comment += `,${dbmTraceComment}` | ||
| } else if (Array.isArray(comment)) { | ||
| comment.push(dbmTraceComment) | ||
| } // do nothing if the comment is not a string or an array | ||
| } else { | ||
| comment = dbmTraceComment | ||
| } | ||
| return comment | ||
| } | ||
| } | ||
| const MAX_DEPTH = 10 | ||
| const MAX_QUERY_LENGTH = 10_000 | ||
| function extractQuery (statements) { | ||
| if (statements.length === 1 && statements[0].q) return statements[0].q | ||
| const extractedQueries = [] | ||
| for (let i = 0; i < statements.length; i++) { | ||
| if (statements[i].q) { | ||
| extractedQueries.push(statements[i].q) | ||
| } | ||
| } | ||
| return extractedQueries | ||
| } | ||
| /** | ||
| * @param {Record<string, unknown> | unknown[] | undefined} cmd | ||
| * @param {'none' | 'types' | 'redact'} mode | ||
| */ | ||
| function getQuery (cmd, mode) { | ||
| if (!cmd || (typeof cmd !== 'object' && !Array.isArray(cmd))) return | ||
| if (Array.isArray(cmd)) return sanitiseAndStringify(extractQuery(cmd), mode) | ||
| if (cmd.query) return sanitiseAndStringify(cmd.query, mode) | ||
| if (cmd.filter) return sanitiseAndStringify(cmd.filter, mode) | ||
| if (cmd.pipeline) return sanitiseAndStringify(cmd.pipeline, mode) | ||
| if (cmd.deletes) return sanitiseAndStringify(extractQuery(cmd.deletes), mode) | ||
| if (cmd.updates) return sanitiseAndStringify(extractQuery(cmd.updates), mode) | ||
| } | ||
| function getResource (plugin, ns, query, operationName) { | ||
| let resource = `${operationName} ${ns}` | ||
| if (plugin.config.queryInResourceName && query) { | ||
| resource += ` ${query}` | ||
| } | ||
| return resource | ||
| } | ||
| function truncate (input) { | ||
| return input.length > MAX_QUERY_LENGTH ? input.slice(0, MAX_QUERY_LENGTH) : input | ||
| } | ||
| // Depth doubles as the cycle bound: a cycle pushes past MAX_DEPTH and bails, | ||
| // after which the slow path catches it via its ancestor stack. | ||
| /** @param {unknown} input */ | ||
| function canStringifyDirect (input) { | ||
| if (input === null || | ||
| typeof input !== 'object' || | ||
| ArrayBuffer.isView(input) || | ||
| input._bsontype !== undefined || | ||
| isRegExp(input) || | ||
| isMap(input) || | ||
| typeof input.toJSON === 'function') { | ||
| return false | ||
| } | ||
| return canStringifyDirectWalk(input, 1) | ||
| } | ||
| /** | ||
| * @param {Record<string, unknown> | unknown[]} value | ||
| * @param {number} depth | ||
| */ | ||
| function canStringifyDirectWalk (value, depth) { | ||
| if (depth > MAX_DEPTH) return false | ||
| const children = Array.isArray(value) ? value : Object.values(value) | ||
| for (const child of children) { | ||
| if (child === null || | ||
| typeof child === 'string' || | ||
| typeof child === 'number' || | ||
| typeof child === 'boolean') { | ||
| continue | ||
| } | ||
| if (typeof child !== 'object' || | ||
| ArrayBuffer.isView(child) || | ||
| child._bsontype !== undefined || | ||
| isRegExp(child) || | ||
| isMap(child) || | ||
| typeof child.toJSON === 'function') { | ||
| return false | ||
| } | ||
| if (!canStringifyDirectWalk(child, depth + 1)) return false | ||
| } | ||
| return true | ||
| } | ||
| /** | ||
| * @param {Record<string, unknown> | unknown[]} input | ||
| * @param {'none' | 'types' | 'redact'} mode | ||
| */ | ||
| function sanitiseAndStringify (input, mode) { | ||
| if (mode === 'none') { | ||
| if (canStringifyDirect(input)) return JSON.stringify(input) | ||
| return buildNone(input, []) | ||
| } | ||
| if (mode === 'redact') return buildRedact(input, []) | ||
| return buildTypes(input, []) | ||
| } | ||
| const REDACT_LEAF = '"?"' | ||
| /** | ||
| * @param {RegExp} value | ||
| * @returns {string} | ||
| */ | ||
| function stringifyRegExp (value) { | ||
| return `{"$regex":${JSON.stringify(value.source)},"$options":${JSON.stringify(value.flags)}}` | ||
| } | ||
| /** | ||
| * @param {Record<string, unknown> | unknown[]} value | ||
| * @param {object[]} ancestors | ||
| * @returns {string | undefined} | ||
| */ | ||
| function buildNone (value, ancestors) { | ||
| // ArrayBuffer views (Buffer, every TypedArray, DataView) and Binary BSON | ||
| // wrappers redact at the leaf; the walker neither recurses into the bytes | ||
| // nor invokes any custom conversion. | ||
| const bsontype = value._bsontype | ||
| if (ArrayBuffer.isView(value) || bsontype === 'Binary' || | ||
| ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { | ||
| return REDACT_LEAF | ||
| } | ||
| if (isRegExp(value)) return stringifyRegExp(value) | ||
| // Mirror JSON.stringify's contract: when `toJSON` is present, walk its | ||
| // result (wrappers like Timestamp / Decimal128 expand to a small object, | ||
| // ObjectId / Date flatten to a primitive). | ||
| if (typeof value.toJSON === 'function') { | ||
| const json = value.toJSON() | ||
| if (json === value) return REDACT_LEAF | ||
| // JSON.stringify keeps a null result as null (an invalid Date's toJSON | ||
| // returns null); only function / symbol / undefined results drop the key. | ||
| if (json === null) return 'null' | ||
| if (typeof json !== 'object') return classifyLeafForNone(json) | ||
| // A wrapper that exposes binary state through toJSON (Buffer-backed | ||
| // class with WeakMap state, etc.) returns a TypedArray here. Re-screen | ||
| // before the per-key walk would expand it element by element. | ||
| if (ArrayBuffer.isView(json) || json._bsontype === 'Binary') return REDACT_LEAF | ||
| value = json | ||
| } else if (bsontype !== undefined) { | ||
| return REDACT_LEAF | ||
| } | ||
| // The driver serializes a Map via its entries; mirror that as a document so | ||
| // the tag matches the wire shape. | ||
| if (isMap(value)) value = Object.fromEntries(value) | ||
| ancestors.push(value) | ||
| let result | ||
| if (Array.isArray(value)) { | ||
| result = '[' | ||
| let sep = '' | ||
| for (let i = 0; i < value.length; i++) { | ||
| // JSON.stringify renders unsupported leaves (function, symbol, undefined) as null in arrays. | ||
| result += sep + (classifyForNone(value[i], ancestors) ?? 'null') | ||
| sep = ',' | ||
| } | ||
| result += ']' | ||
| } else { | ||
| result = '{' | ||
| let sep = '' | ||
| for (const key of Object.keys(value)) { | ||
| const childResult = classifyForNone(value[key], ancestors) | ||
| if (childResult === undefined) continue | ||
| result += sep + JSON.stringify(key) + ':' + childResult | ||
| sep = ',' | ||
| } | ||
| result += '}' | ||
| } | ||
| ancestors.pop() | ||
| return result | ||
| } | ||
| /** | ||
| * @param {unknown} child | ||
| * @param {object[]} ancestors | ||
| * @returns {string | undefined} | ||
| */ | ||
| function classifyForNone (child, ancestors) { | ||
| if (typeof child !== 'object') return classifyLeafForNone(child) | ||
| if (child === null) return 'null' | ||
| return buildNone(child, ancestors) | ||
| } | ||
| /** | ||
| * @param {unknown} leaf | ||
| * @returns {string | undefined} | ||
| */ | ||
| function classifyLeafForNone (leaf) { | ||
| // Implicit `undefined` for function / symbol / undefined matches the | ||
| // contract callers rely on: JSON.stringify drops those property values | ||
| // inside objects and writes `null` in arrays. | ||
| switch (typeof leaf) { | ||
| case 'string': return JSON.stringify(leaf) | ||
| case 'number': return Number.isFinite(leaf) ? String(leaf) : 'null' | ||
| case 'boolean': return leaf ? 'true' : 'false' | ||
| case 'bigint': return `"${String(leaf)}"` | ||
| } | ||
| } | ||
| /** | ||
| * @param {Record<string, unknown> | unknown[]} value | ||
| * @param {object[]} ancestors | ||
| */ | ||
| function buildRedact (value, ancestors) { | ||
| const bsontype = value._bsontype | ||
| if (ArrayBuffer.isView(value) || bsontype === 'Binary' || isRegExp(value) || | ||
| ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { | ||
| return REDACT_LEAF | ||
| } | ||
| // Mirror JSON.stringify: when `toJSON` is present, walk its result (which | ||
| // wrappers like Timestamp / Decimal128 expand to `{$timestamp: "..."}` etc). | ||
| // A primitive, null, or self-reference collapses to the sentinel — master's | ||
| // `value === original` short-circuit. | ||
| if (typeof value.toJSON === 'function') { | ||
| const json = value.toJSON() | ||
| if (typeof json !== 'object' || json === null || json === value) return REDACT_LEAF | ||
| // Re-screen: toJSON can return a TypedArray or Binary BSON wrapper. | ||
| if (ArrayBuffer.isView(json) || json._bsontype === 'Binary') return REDACT_LEAF | ||
| value = json | ||
| } else if (bsontype !== undefined) { | ||
| return REDACT_LEAF | ||
| } | ||
| if (isMap(value)) value = Object.fromEntries(value) | ||
| ancestors.push(value) | ||
| let result | ||
| if (Array.isArray(value)) { | ||
| result = '[' | ||
| let sep = '' | ||
| for (let i = 0; i < value.length; i++) { | ||
| result += sep + classifyForRedact(value[i], ancestors) | ||
| sep = ',' | ||
| } | ||
| result += ']' | ||
| } else { | ||
| result = '{' | ||
| let sep = '' | ||
| for (const key of Object.keys(value)) { | ||
| result += sep + JSON.stringify(key) + ':' + classifyForRedact(value[key], ancestors) | ||
| sep = ',' | ||
| } | ||
| result += '}' | ||
| } | ||
| ancestors.pop() | ||
| return result | ||
| } | ||
| /** | ||
| * @param {unknown} child | ||
| * @param {object[]} ancestors | ||
| */ | ||
| function classifyForRedact (child, ancestors) { | ||
| if (typeof child !== 'object' || child === null) return REDACT_LEAF | ||
| return buildRedact(child, ancestors) | ||
| } | ||
| const TYPE_OBJECT = '"object"' | ||
| const TYPE_NULL = '"null"' | ||
| const TYPE_BY_TYPEOF = { | ||
| string: '"string"', | ||
| number: '"number"', | ||
| boolean: '"boolean"', | ||
| bigint: '"bigint"', | ||
| undefined: '"undefined"', | ||
| } | ||
| /** | ||
| * @param {Record<string, unknown> | unknown[]} value | ||
| * @param {object[]} ancestors | ||
| */ | ||
| function buildTypes (value, ancestors) { | ||
| const bsontype = value._bsontype | ||
| if (ArrayBuffer.isView(value) || bsontype === 'Binary' || isRegExp(value) || | ||
| ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { | ||
| return TYPE_OBJECT | ||
| } | ||
| if (typeof value.toJSON === 'function') { | ||
| const json = value.toJSON() | ||
| if (typeof json !== 'object' || | ||
| json === null || | ||
| json === value || | ||
| ArrayBuffer.isView(json) || | ||
| json._bsontype === 'Binary') { | ||
| return TYPE_OBJECT | ||
| } | ||
| value = json | ||
| } else if (bsontype !== undefined) { | ||
| return TYPE_OBJECT | ||
| } | ||
| if (isMap(value)) value = Object.fromEntries(value) | ||
| ancestors.push(value) | ||
| let result | ||
| if (Array.isArray(value)) { | ||
| result = '[' | ||
| let sep = '' | ||
| for (let i = 0; i < value.length; i++) { | ||
| // JSON.stringify renders unsupported leaves (function, symbol) as null in arrays. | ||
| result += sep + (classifyForTypes(value[i], ancestors) ?? 'null') | ||
| sep = ',' | ||
| } | ||
| result += ']' | ||
| } else { | ||
| result = '{' | ||
| let sep = '' | ||
| for (const key of Object.keys(value)) { | ||
| const childResult = classifyForTypes(value[key], ancestors) | ||
| if (childResult === undefined) continue | ||
| result += sep + JSON.stringify(key) + ':' + childResult | ||
| sep = ',' | ||
| } | ||
| result += '}' | ||
| } | ||
| ancestors.pop() | ||
| return result | ||
| } | ||
| /** | ||
| * @param {unknown} child | ||
| * @param {object[]} ancestors | ||
| */ | ||
| function classifyForTypes (child, ancestors) { | ||
| if (typeof child !== 'object') return TYPE_BY_TYPEOF[typeof child] | ||
| if (child === null) return TYPE_NULL | ||
| return buildTypes(child, ancestors) | ||
| } | ||
| /** @param {unknown} value */ | ||
| function normaliseObfuscateQuery (value) { | ||
| if (value === 'types' || value === 'redact') return value | ||
| return 'none' | ||
| } | ||
| function isHeartbeat (ops, config) { | ||
| // Check if it's a heartbeat command https://github.com/mongodb/specifications/blob/master/source/mongodb-handshake/handshake.md | ||
| return ( | ||
| ops && | ||
| typeof ops === 'object' && | ||
| (ops.hello === 1 || ops.helloOk === true || ops.ismaster === 1 || ops.isMaster === 1) | ||
| ) | ||
| } | ||
| module.exports = MongodbCorePlugin |
@@ -90,3 +90,3 @@ 'use strict' | ||
| } | ||
| if (this.numFailedSuites > 0) { | ||
| if (status === 'fail' && this.numFailedSuites > 0) { | ||
| let errorMessage = `Test suites failed: ${this.numFailedSuites}.` | ||
@@ -117,2 +117,3 @@ if (this.numFailedTests > 0) { | ||
| this.numFailedTests = 0 | ||
| this.numFailedSuites = 0 | ||
| }) | ||
@@ -413,3 +414,3 @@ | ||
| } | ||
| if (testStatus === 'fail') { | ||
| if (finalStatus === 'fail') { | ||
| this.numFailedTests++ | ||
@@ -416,0 +417,0 @@ } |
@@ -70,2 +70,3 @@ 'use strict' | ||
| codeOwnersEntries: this.codeOwnersEntries, | ||
| testEnvironmentMetadata: this.testEnvironmentMetadata, | ||
| }) | ||
@@ -196,3 +197,3 @@ }) | ||
| this.addSub('ci:vitest:test:pass', ({ span, task, finalStatus, earlyFlakeAbortReason }) => { | ||
| this.addSub('ci:vitest:test:pass', ({ span, task, finalStatus, earlyFlakeAbortReason, promises }) => { | ||
| if (span) { | ||
@@ -207,4 +208,17 @@ this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'test', this.getTestTelemetryTags(span)) | ||
| } | ||
| span.finish(this.taskToFinishTime.get(task)) | ||
| finishAllTraceSpans(span) | ||
| const finish = () => { | ||
| span.finish(this.taskToFinishTime.get(task)) | ||
| finishAllTraceSpans(span) | ||
| } | ||
| if (finalStatus) { | ||
| if (promises && this.diBreakpointHitPromise) { | ||
| promises.hitBreakpointPromise = this.waitForPreparedDiBreakpointHit().then(finish) | ||
| return | ||
| } | ||
| finish() | ||
| this.cancelDiBreakpointHitWait() | ||
| return | ||
| } | ||
| finish() | ||
| } | ||
@@ -218,2 +232,3 @@ }) | ||
| shouldSetProbe, | ||
| shouldWaitForHitProbe, | ||
| promises, | ||
@@ -234,3 +249,4 @@ hasFailedAllRetries, | ||
| this.testErrorStackIndex = stackIndex | ||
| promises.setProbePromise = setProbePromise | ||
| this.prepareDiBreakpointHitWait() | ||
| promises.setProbePromise = this.waitForDiOperation(setProbePromise) | ||
| } | ||
@@ -256,10 +272,29 @@ } | ||
| } | ||
| if (duration) { | ||
| span.finish(span._startTime + duration - MILLISECONDS_TO_SUBTRACT_FROM_FAILED_TEST_DURATION) // milliseconds | ||
| } else { | ||
| span.finish() // `duration` is empty for retries, so we'll use clock time | ||
| const finish = () => { | ||
| if (duration) { | ||
| span.finish(span._startTime + duration - MILLISECONDS_TO_SUBTRACT_FROM_FAILED_TEST_DURATION) // milliseconds | ||
| } else { | ||
| span.finish() // `duration` is empty for retries, so we'll use clock time | ||
| } | ||
| finishAllTraceSpans(span) | ||
| } | ||
| finishAllTraceSpans(span) | ||
| if (!shouldSetProbe && finalStatus && promises && this.diBreakpointHitPromise) { | ||
| promises.hitBreakpointPromise = this.waitForPreparedDiBreakpointHit().then(finish) | ||
| return | ||
| } | ||
| finish() | ||
| if (shouldWaitForHitProbe) { | ||
| this.prepareDiBreakpointHitWait() | ||
| } else if (!shouldSetProbe) { | ||
| this.cancelDiBreakpointHitWait() | ||
| } | ||
| }) | ||
| this.addSub('ci:vitest:test:di:wait', ({ promises }) => { | ||
| if (this.di) { | ||
| promises.hitBreakpointPromise = this.waitForDiBreakpointHits() | ||
| } | ||
| }) | ||
| this.addSub('ci:vitest:test:skip', ({ | ||
@@ -298,2 +333,3 @@ testName, | ||
| repositoryRoot, | ||
| testEnvironmentMetadata, | ||
| requestErrorTags, | ||
@@ -308,2 +344,8 @@ testSuiteAbsolutePath, | ||
| const { testSessionId, testModuleId } = ctx | ||
| if (testEnvironmentMetadata) { | ||
| this.testEnvironmentMetadata = { | ||
| ...this.testEnvironmentMetadata, | ||
| ...testEnvironmentMetadata, | ||
| } | ||
| } | ||
| this._setRepositoryRoot(repositoryRoot, codeOwnersEntries) | ||
@@ -310,0 +352,0 @@ this.command = testCommand |
@@ -5,4 +5,3 @@ 'use strict' | ||
| const { incomingHttpRequestStart } = require('./channels') | ||
| const openaiIntegration = require('./integrations/openai') | ||
| const vercelAiIntegration = require('./integrations/vercel-ai') | ||
| const integrations = require('./integrations') | ||
| const AIGuard = require('./sdk') | ||
@@ -12,5 +11,2 @@ | ||
| let aiguard | ||
| let block | ||
| let disableOpenAIIntegration | ||
| let disableVercelAiIntegration | ||
@@ -26,7 +22,6 @@ function onIncomingHttpRequestStart () { | ||
| aiguard = new AIGuard(tracer, config) | ||
| block = config.experimental?.aiguard?.block !== false | ||
| const block = config.experimental?.aiguard?.block !== false | ||
| incomingHttpRequestStart.subscribe(onIncomingHttpRequestStart) | ||
| disableOpenAIIntegration = openaiIntegration.enable(aiguard, block) | ||
| disableVercelAiIntegration = vercelAiIntegration.enable(aiguard, block) | ||
| integrations.enable(aiguard, block) | ||
@@ -36,3 +31,3 @@ isEnabled = true | ||
| log.error('AIGuard: unexpected error during initialization: %s', err.message) | ||
| disable() | ||
| reset() | ||
| } | ||
@@ -44,13 +39,13 @@ } | ||
| reset() | ||
| } | ||
| function reset () { | ||
| incomingHttpRequestStart.unsubscribe(onIncomingHttpRequestStart) | ||
| disableOpenAIIntegration?.() | ||
| disableVercelAiIntegration?.() | ||
| integrations.disable() | ||
| aiguard = undefined | ||
| isEnabled = false | ||
| block = false | ||
| disableOpenAIIntegration = undefined | ||
| disableVercelAiIntegration = undefined | ||
| } | ||
| module.exports = { enable, disable } |
@@ -19,36 +19,18 @@ 'use strict' | ||
| let isEnabled = false | ||
| let aiguard | ||
| let opts | ||
| /** | ||
| * Subscribes AI Guard to OpenAI lifecycle channels. | ||
| * | ||
| * @param {object} aiguard | ||
| * @param {object} aiguardInstance | ||
| * @param {boolean} block | ||
| * @returns {() => void} | ||
| */ | ||
| function enable (aiguard, block) { | ||
| const opts = { block, source: SOURCE_AUTO, integration: 'openai' } | ||
| function enable (aiguardInstance, block) { | ||
| if (isEnabled) return | ||
| function onChatCompletionsBefore (ctx) { | ||
| pushEvaluation(ctx, aiguard, getChatCompletionsInputMessages(ctx.args?.[0]), opts) | ||
| } | ||
| aiguard = aiguardInstance | ||
| opts = { block, source: SOURCE_AUTO, integration: 'openai' } | ||
| function onChatCompletionsAfter (ctx) { | ||
| const inputMessages = getChatCompletionsInputMessages(ctx.args?.[0]) | ||
| if (!inputMessages?.length) return | ||
| for (const message of getChatCompletionsOutputMessages(ctx.body)) { | ||
| pushEvaluation(ctx, aiguard, [...inputMessages, message], opts) | ||
| } | ||
| } | ||
| function onResponsesBefore (ctx) { | ||
| pushEvaluation(ctx, aiguard, getResponsesInputMessages(ctx.args?.[0]), opts) | ||
| } | ||
| function onResponsesAfter (ctx) { | ||
| const inputMessages = getResponsesInputMessages(ctx.args?.[0]) | ||
| if (!inputMessages?.length) return | ||
| const outputMessages = getResponsesOutputMessages(ctx.body) | ||
| if (!outputMessages.length) return | ||
| pushEvaluation(ctx, aiguard, [...inputMessages, ...outputMessages], opts) | ||
| } | ||
| chatCompletionsBeforeChannel.subscribe(onChatCompletionsBefore) | ||
@@ -59,10 +41,42 @@ chatCompletionsAfterChannel.subscribe(onChatCompletionsAfter) | ||
| return function disable () { | ||
| chatCompletionsBeforeChannel.unsubscribe(onChatCompletionsBefore) | ||
| chatCompletionsAfterChannel.unsubscribe(onChatCompletionsAfter) | ||
| responsesBeforeChannel.unsubscribe(onResponsesBefore) | ||
| responsesAfterChannel.unsubscribe(onResponsesAfter) | ||
| isEnabled = true | ||
| } | ||
| function disable () { | ||
| if (!isEnabled) return | ||
| chatCompletionsBeforeChannel.unsubscribe(onChatCompletionsBefore) | ||
| chatCompletionsAfterChannel.unsubscribe(onChatCompletionsAfter) | ||
| responsesBeforeChannel.unsubscribe(onResponsesBefore) | ||
| responsesAfterChannel.unsubscribe(onResponsesAfter) | ||
| aiguard = undefined | ||
| opts = undefined | ||
| isEnabled = false | ||
| } | ||
| function onChatCompletionsBefore (ctx) { | ||
| pushEvaluation(ctx, aiguard, getChatCompletionsInputMessages(ctx.args?.[0]), opts) | ||
| } | ||
| function onChatCompletionsAfter (ctx) { | ||
| const inputMessages = getChatCompletionsInputMessages(ctx.args?.[0]) | ||
| if (!inputMessages?.length) return | ||
| for (const message of getChatCompletionsOutputMessages(ctx.body)) { | ||
| pushEvaluation(ctx, aiguard, [...inputMessages, message], opts) | ||
| } | ||
| } | ||
| module.exports = { enable } | ||
| function onResponsesBefore (ctx) { | ||
| pushEvaluation(ctx, aiguard, getResponsesInputMessages(ctx.args?.[0]), opts) | ||
| } | ||
| function onResponsesAfter (ctx) { | ||
| const inputMessages = getResponsesInputMessages(ctx.args?.[0]) | ||
| if (!inputMessages?.length) return | ||
| const outputMessages = getResponsesOutputMessages(ctx.body) | ||
| if (!outputMessages.length) return | ||
| pushEvaluation(ctx, aiguard, [...inputMessages, ...outputMessages], opts) | ||
| } | ||
| module.exports = { enable, disable } |
@@ -14,30 +14,18 @@ 'use strict' | ||
| let isEnabled = false | ||
| let aiguard | ||
| let opts | ||
| /** | ||
| * Subscribes AI Guard to Vercel AI lifecycle channels. | ||
| * | ||
| * @param {object} aiguard | ||
| * @param {object} aiguardInstance | ||
| * @param {boolean} block | ||
| * @returns {() => void} | ||
| */ | ||
| function enable (aiguard, block) { | ||
| const opts = { block, source: SOURCE_AUTO, integration: 'ai' } | ||
| function enable (aiguardInstance, block) { | ||
| if (isEnabled) return | ||
| function onBefore (ctx) { | ||
| pushEvaluation(ctx, aiguard, convertVercelPromptToMessages(ctx.prompt), opts) | ||
| } | ||
| aiguard = aiguardInstance | ||
| opts = { block, source: SOURCE_AUTO, integration: 'ai' } | ||
| function onGenerateAfter (ctx) { | ||
| const inputMessages = convertVercelPromptToMessages(ctx.prompt) | ||
| if (!inputMessages.length || !ctx.result?.content?.length) return | ||
| pushEvaluation(ctx, aiguard, buildOutputMessages(inputMessages, ctx.result.content), opts) | ||
| } | ||
| function onStreamAfter (ctx) { | ||
| const inputMessages = convertVercelPromptToMessages(ctx.prompt) | ||
| if (!inputMessages.length || !ctx.chunks?.length) return | ||
| pushEvaluation(ctx, aiguard, buildOutputMessages(inputMessages, getStreamContent(ctx.chunks)), opts) | ||
| } | ||
| doGenerateBeforeChannel.subscribe(onBefore) | ||
@@ -48,10 +36,36 @@ doGenerateAfterChannel.subscribe(onGenerateAfter) | ||
| return function disable () { | ||
| doGenerateBeforeChannel.unsubscribe(onBefore) | ||
| doGenerateAfterChannel.unsubscribe(onGenerateAfter) | ||
| doStreamBeforeChannel.unsubscribe(onBefore) | ||
| doStreamAfterChannel.unsubscribe(onStreamAfter) | ||
| } | ||
| isEnabled = true | ||
| } | ||
| function disable () { | ||
| if (!isEnabled) return | ||
| doGenerateBeforeChannel.unsubscribe(onBefore) | ||
| doGenerateAfterChannel.unsubscribe(onGenerateAfter) | ||
| doStreamBeforeChannel.unsubscribe(onBefore) | ||
| doStreamAfterChannel.unsubscribe(onStreamAfter) | ||
| aiguard = undefined | ||
| opts = undefined | ||
| isEnabled = false | ||
| } | ||
| function onBefore (ctx) { | ||
| pushEvaluation(ctx, aiguard, convertVercelPromptToMessages(ctx.prompt), opts) | ||
| } | ||
| function onGenerateAfter (ctx) { | ||
| const inputMessages = convertVercelPromptToMessages(ctx.prompt) | ||
| if (!inputMessages.length || !ctx.result?.content?.length) return | ||
| pushEvaluation(ctx, aiguard, buildOutputMessages(inputMessages, ctx.result.content), opts) | ||
| } | ||
| function onStreamAfter (ctx) { | ||
| const inputMessages = convertVercelPromptToMessages(ctx.prompt) | ||
| if (!inputMessages.length || !ctx.chunks?.length) return | ||
| pushEvaluation(ctx, aiguard, buildOutputMessages(inputMessages, getStreamContent(ctx.chunks)), opts) | ||
| } | ||
| /** | ||
@@ -80,2 +94,2 @@ * Converts Vercel stream chunks into the content shape used by doGenerate results. | ||
| module.exports = { enable } | ||
| module.exports = { enable, disable } |
@@ -75,10 +75,13 @@ 'use strict' | ||
| const req = getActiveRequest() | ||
| if (!req) return | ||
| const requestData = graphqlRequestData.get(req) | ||
| if (requestData) { | ||
| // Set isInGraphqlRequest=true since this function only runs for GraphQL requests | ||
| // This works for both Apollo v4 (middleware) and v5 (HTTP server) contexts | ||
| requestData.isInGraphqlRequest = true | ||
| addSpecificEndpoint(req, specificBlockingTypes.GRAPHQL) | ||
| let requestData = graphqlRequestData.get(req) | ||
| if (!requestData) { | ||
| // executeHTTPGraphQLRequest is the GraphQL request boundary, so seed here | ||
| // when no upstream hook (express4 middleware, drainHttpServer) has run. | ||
| requestData = { blocked: false } | ||
| graphqlRequestData.set(req, requestData) | ||
| } | ||
| requestData.isInGraphqlRequest = true | ||
| addSpecificEndpoint(req, specificBlockingTypes.GRAPHQL) | ||
| } | ||
@@ -85,0 +88,0 @@ |
@@ -49,13 +49,4 @@ 'use strict' | ||
| } | ||
| return store | ||
| } | ||
| // mquery analyzes and marks on the same channel: its tracing start both reaches | ||
| // the driver scope and is the first place the filters are available. | ||
| const onStartAndBind = (message) => { | ||
| onStart(message) | ||
| return markExecStore() | ||
| } | ||
| this.addSub('datadog:mongodb:collection:filter:start', onStart) | ||
@@ -69,4 +60,6 @@ | ||
| // mquery analyzes filters at the prepare step and marks the execution scope at | ||
| // tracing start, where the deferred driver call runs under the marked store. | ||
| this.addSub('datadog:mquery:filter:prepare', onStart) | ||
| this.addBind('tracing:datadog:mquery:filter:start', onStartAndBind) | ||
| this.addBind('tracing:datadog:mquery:filter:start', markExecStore) | ||
| } | ||
@@ -73,0 +66,0 @@ |
@@ -12,2 +12,3 @@ 'use strict' | ||
| const probeIdToResolveBreakpointRemove = new Map() | ||
| const drainRequestIdToResolveBreakpointHit = new Map() | ||
@@ -61,2 +62,17 @@ class TestVisDynamicInstrumentation { | ||
| /** | ||
| * Waits until all breakpoint hits already being handled by the DI worker have been posted back. | ||
| * | ||
| * @returns {Promise<void>} | ||
| */ | ||
| waitForInFlightBreakpointHits () { | ||
| if (!this.worker) return Promise.resolve() | ||
| const requestId = randomUUID() | ||
| return new Promise(resolve => { | ||
| drainRequestIdToResolveBreakpointHit.set(requestId, resolve) | ||
| this.breakpointHitChannel.port2.postMessage({ drainRequestId: requestId }) | ||
| }) | ||
| } | ||
| isReady () { | ||
@@ -135,3 +151,12 @@ return this._readyPromise | ||
| this.breakpointHitChannel.port2.on('message', ({ snapshot }) => { | ||
| this.breakpointHitChannel.port2.on('message', ({ snapshot, drainRequestId }) => { | ||
| if (drainRequestId) { | ||
| const resolve = drainRequestIdToResolveBreakpointHit.get(drainRequestId) | ||
| if (resolve) { | ||
| resolve() | ||
| drainRequestIdToResolveBreakpointHit.delete(drainRequestId) | ||
| } | ||
| return | ||
| } | ||
| const { probe: { id: probeId } } = snapshot | ||
@@ -138,0 +163,0 @@ const onHit = this.onHitBreakpointByProbeId.get(probeId) |
@@ -31,8 +31,16 @@ 'use strict' | ||
| const log = require('../../../log') | ||
| const processTags = require('../../../process-tags') | ||
| let processTags | ||
| if (config.propagateProcessTags?.enabled) { | ||
| processTags = require('../../../process-tags') | ||
| processTags.initialize() | ||
| } | ||
| let sessionStarted = false | ||
| let inFlightBreakpointHits = 0 | ||
| let isBreakpointHitDrainScheduled = false | ||
| const breakpointIdToProbe = new Map() | ||
| const probeIdToBreakpointId = new Map() | ||
| const breakpointHitDrainRequests = [] | ||
@@ -46,2 +54,51 @@ const limits = { | ||
| /** | ||
| * Remove empty collection arrays before sending snapshots through the Test Optimization logs path. | ||
| * | ||
| * @param {object} value - Snapshot object or sub-object. | ||
| * @returns {void} | ||
| */ | ||
| function removeEmptyCollectionProperties (value) { | ||
| const stack = [value] | ||
| while (stack.length > 0) { | ||
| const current = stack.pop() | ||
| if (!current || typeof current !== 'object') continue | ||
| if (Array.isArray(current)) { | ||
| for (const item of current) { | ||
| if (item && typeof item === 'object') { | ||
| stack.push(item) | ||
| } | ||
| } | ||
| continue | ||
| } | ||
| if (Array.isArray(current.elements)) { | ||
| if (current.elements.length === 0) { | ||
| delete current.elements | ||
| } else { | ||
| stack.push(current.elements) | ||
| } | ||
| } | ||
| if (Array.isArray(current.entries)) { | ||
| if (current.entries.length === 0) { | ||
| delete current.entries | ||
| } else { | ||
| stack.push(current.entries) | ||
| } | ||
| } | ||
| for (const key of Object.keys(current)) { | ||
| if (key === 'elements' || key === 'entries') continue | ||
| const child = current[key] | ||
| if (child && typeof child === 'object') { | ||
| stack.push(child) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| session.on('Debugger.paused', async ({ params: { hitBreakpoints: [hitBreakpoint], callFrames } }) => { | ||
@@ -54,28 +111,43 @@ const probe = breakpointIdToProbe.get(hitBreakpoint) | ||
| const stack = await getStackFromCallFrames(callFrames) | ||
| inFlightBreakpointHits++ | ||
| try { | ||
| const stack = await getStackFromCallFrames(callFrames) | ||
| const { processLocalState } = await getLocalStateForCallFrame(callFrames[0], limits) | ||
| const { processLocalState } = await getLocalStateForCallFrame(callFrames[0], limits) | ||
| await session.post('Debugger.resume') | ||
| await session.post('Debugger.resume') | ||
| const snapshot = { | ||
| id: randomUUID(), | ||
| timestamp: Date.now(), | ||
| probe: { | ||
| id: probe.id, | ||
| version: '0', | ||
| location: probe.location, | ||
| }, | ||
| captures: { | ||
| lines: { [probe.location.lines[0]]: { locals: processLocalState() } }, | ||
| }, | ||
| stack, | ||
| language: 'javascript', | ||
| } | ||
| const snapshot = { | ||
| id: randomUUID(), | ||
| timestamp: Date.now(), | ||
| probe: { | ||
| id: probe.id, | ||
| version: 0, | ||
| location: probe.location, | ||
| }, | ||
| captures: { | ||
| lines: { [probe.location.lines[0]]: { locals: processLocalState() } }, | ||
| }, | ||
| stack, | ||
| language: 'javascript', | ||
| } | ||
| if (config.propagateProcessTags?.enabled) { | ||
| snapshot[processTags.DYNAMIC_INSTRUMENTATION_FIELD_NAME] = processTags.tagsObject | ||
| removeEmptyCollectionProperties(snapshot.captures) | ||
| if (processTags) { | ||
| snapshot[processTags.DYNAMIC_INSTRUMENTATION_FIELD_NAME] = processTags.tagsObject | ||
| } | ||
| breakpointHitChannel.postMessage({ snapshot }) | ||
| } finally { | ||
| inFlightBreakpointHits-- | ||
| scheduleBreakpointHitDrain() | ||
| } | ||
| }) | ||
| breakpointHitChannel.postMessage({ snapshot }) | ||
| breakpointHitChannel.on('message', ({ drainRequestId }) => { | ||
| if (!drainRequestId) return | ||
| breakpointHitDrainRequests.push(drainRequestId) | ||
| scheduleBreakpointHitDrain() | ||
| }) | ||
@@ -160,1 +232,20 @@ | ||
| } | ||
| function drainBreakpointHitRequests () { | ||
| if (inFlightBreakpointHits !== 0) return | ||
| for (const drainRequestId of breakpointHitDrainRequests) { | ||
| breakpointHitChannel.postMessage({ drainRequestId }) | ||
| } | ||
| breakpointHitDrainRequests.length = 0 | ||
| } | ||
| function scheduleBreakpointHitDrain () { | ||
| if (isBreakpointHitDrainScheduled) return | ||
| isBreakpointHitDrainScheduled = true | ||
| setImmediate(() => { | ||
| isBreakpointHitDrainScheduled = false | ||
| drainBreakpointHitRequests() | ||
| }) | ||
| } |
@@ -71,2 +71,5 @@ 'use strict' | ||
| this._codeCoverageReportUrl = this._url | ||
| // Screenshot media uploads go through the Agent's evp_proxy: the uploader prefixes the | ||
| // path with evpProxyPrefix and sets X-Datadog-EVP-Subdomain: api (see uploadTestScreenshot). | ||
| this._testScreenshotUploadUrl = this._url | ||
| if (testOptimization.DD_TEST_FAILED_TEST_REPLAY_ENABLED) { | ||
@@ -73,0 +76,0 @@ const canFowardLogs = getCanForwardDebuggerLogs(err, agentInfo) |
@@ -34,2 +34,4 @@ 'use strict' | ||
| this._apiUrl = url || new URL(`https://api.${site}`) | ||
| // Media uploads (raw bytes + DD-API-KEY) go to the same api.<site> host as the rest of the API. | ||
| this._testScreenshotUploadUrl = this._apiUrl | ||
| // Agentless is always gzip compatible | ||
@@ -44,2 +46,3 @@ this._isGzipCompatible = true | ||
| this._apiUrl = apiUrl | ||
| this._testScreenshotUploadUrl = apiUrl | ||
| } catch (e) { | ||
@@ -46,0 +49,0 @@ log.error('Error setting CI exporter api url', e) |
| 'use strict' | ||
| const { hostname: getHostname } = require('node:os') | ||
| const URL = require('url').URL | ||
| const { version: tracerVersion } = require('../../../../../package.json') | ||
| const { getLibraryConfiguration: getLibraryConfigurationRequest } = require('../requests/get-library-configuration') | ||
@@ -13,2 +15,3 @@ const { getSkippableSuites: getSkippableSuitesRequest } = require('../intelligent-test-runner/get-skippable-suites') | ||
| const { uploadCoverageReport: uploadCoverageReportRequest } = require('../requests/upload-coverage-report') | ||
| const { uploadTestScreenshot: uploadTestScreenshotRequest } = require('../requests/upload-test-screenshot') | ||
| const log = require('../../log') | ||
@@ -19,2 +22,4 @@ const BufferingExporter = require('../../exporters/common/buffering-exporter') | ||
| const hostname = getHostname() | ||
| function getTestConfigurationTags (tags) { | ||
@@ -42,2 +47,30 @@ if (!tags) { | ||
| function appendLogTag (tags, key, value) { | ||
| if (value !== undefined) { | ||
| tags.push(`${key}:${value}`) | ||
| } | ||
| } | ||
| function getLogTags (logMessage, { env, version }, gitRepositoryUrl, gitCommitSha) { | ||
| const tags = [] | ||
| if (Array.isArray(logMessage.ddtags)) { | ||
| for (const tag of logMessage.ddtags) { | ||
| tags.push(tag) | ||
| } | ||
| } else if (logMessage.ddtags) { | ||
| for (const tag of logMessage.ddtags.split(',')) { | ||
| tags.push(tag) | ||
| } | ||
| } | ||
| appendLogTag(tags, 'env', env) | ||
| appendLogTag(tags, 'version', version) | ||
| appendLogTag(tags, 'debugger_version', tracerVersion) | ||
| appendLogTag(tags, 'host_name', hostname) | ||
| appendLogTag(tags, GIT_COMMIT_SHA, gitCommitSha) | ||
| appendLogTag(tags, GIT_REPOSITORY_URL, gitRepositoryUrl) | ||
| return tags.join(',') | ||
| } | ||
| class CiVisibilityExporter extends BufferingExporter { | ||
@@ -55,2 +88,5 @@ constructor (config) { | ||
| this._isTestFailureScreenshotsEnabled = | ||
| Boolean(config?.testOptimization?.DD_TEST_FAILURE_SCREENSHOTS_ENABLED) | ||
| const gitUploadTimeoutId = setTimeout(() => { | ||
@@ -345,11 +381,9 @@ this._resolveGit(new Error('Timeout while uploading git metadata')) | ||
| return { | ||
| ddtags: [ | ||
| ...(logMessage.ddtags || []), | ||
| `${GIT_REPOSITORY_URL}:${gitRepositoryUrl}`, | ||
| `${GIT_COMMIT_SHA}:${gitCommitSha}`, | ||
| ].join(','), | ||
| ...logMessage, | ||
| ddtags: getLogTags(logMessage, { env, version }, gitRepositoryUrl, gitCommitSha), | ||
| level: 'error', | ||
| service, | ||
| hostname, | ||
| dd: { | ||
| ...(logMessage.dd || []), | ||
| ...logMessage.dd, | ||
| service, | ||
@@ -360,3 +394,2 @@ env, | ||
| ddsource: 'dd_debugger', | ||
| ...logMessage, | ||
| } | ||
@@ -467,4 +500,39 @@ } | ||
| } | ||
| /** | ||
| * Returns whether the exporter can upload test failure screenshots. | ||
| * | ||
| * @returns {boolean} | ||
| */ | ||
| canUploadTestScreenshots () { | ||
| return Boolean(this._testScreenshotUploadUrl) && this._isTestFailureScreenshotsEnabled | ||
| } | ||
| /** | ||
| * Uploads a single test screenshot to the Test Optimization media intake. | ||
| * | ||
| * @param {object} options - Upload options | ||
| * @param {string} options.filePath - Path to the screenshot file | ||
| * @param {string} options.traceId - Test trace id used as the screenshot key | ||
| * @param {string} options.idempotencyKey - Stable per-artifact key, reused on retry | ||
| * @param {number} options.capturedAtMs - Capture time in epoch milliseconds | ||
| * @param {Function} callback - Callback function (err) | ||
| */ | ||
| uploadTestScreenshot ({ filePath, traceId, idempotencyKey, capturedAtMs }, callback) { | ||
| if (!this._testScreenshotUploadUrl) { | ||
| return callback(new Error('Test screenshot upload URL not configured')) | ||
| } | ||
| uploadTestScreenshotRequest({ | ||
| filePath, | ||
| traceId, | ||
| idempotencyKey, | ||
| capturedAtMs, | ||
| url: this._testScreenshotUploadUrl, | ||
| isEvpProxy: !!this._isUsingEvpProxy, | ||
| evpProxyPrefix: this.evpProxyPrefix, | ||
| }, callback) | ||
| } | ||
| } | ||
| module.exports = CiVisibilityExporter |
@@ -216,2 +216,3 @@ // This file is generated from packages/dd-trace/src/config/supported-configurations.json | ||
| DD_TRACE_CHILD_PROCESS_ENABLED: boolean; | ||
| DD_TRACE_CLAUDE_AGENT_SDK_ENABLED: boolean; | ||
| DD_TRACE_COLLECTIONS_ENABLED: boolean; | ||
@@ -256,2 +257,4 @@ DD_TRACE_COMMONPLUGIN_ENABLED: boolean; | ||
| DD_TRACE_GOOGLE_GENAI_ENABLED: boolean; | ||
| DD_TRACE_GRAPHQL_COLLAPSE: boolean; | ||
| DD_TRACE_GRAPHQL_DEPTH: number; | ||
| DD_TRACE_GRAPHQL_ENABLED: boolean; | ||
@@ -262,2 +265,3 @@ DD_TRACE_GRAPHQL_ERROR_EXTENSIONS: string[]; | ||
| DD_TRACE_GRAPHQL_TOOLS_EXECUTOR_ENABLED: boolean; | ||
| DD_TRACE_GRAPHQL_VARIABLES: string[]; | ||
| DD_TRACE_GRAPHQL_YOGA_ENABLED: boolean; | ||
@@ -564,2 +568,3 @@ DD_TRACE_GRPC_ENABLED: boolean; | ||
| DD_TEST_FAILED_TEST_REPLAY_ENABLED: boolean; | ||
| DD_TEST_FAILURE_SCREENSHOTS_ENABLED: boolean | undefined; | ||
| DD_TEST_FLEET_CONFIG_PATH: string | undefined; | ||
@@ -797,2 +802,3 @@ DD_TEST_LOCAL_CONFIG_PATH: string | undefined; | ||
| DD_TEST_FAILED_TEST_REPLAY_ENABLED: boolean; | ||
| DD_TEST_FAILURE_SCREENSHOTS_ENABLED: boolean | undefined; | ||
| DD_TEST_FLEET_CONFIG_PATH: string | undefined; | ||
@@ -876,2 +882,3 @@ DD_TEST_LOCAL_CONFIG_PATH: string | undefined; | ||
| DD_TRACE_CHILD_PROCESS_ENABLED: boolean; | ||
| DD_TRACE_CLAUDE_AGENT_SDK_ENABLED: boolean; | ||
| DD_TRACE_CLIENT_IP_ENABLED: boolean; | ||
@@ -926,2 +933,4 @@ DD_TRACE_CLIENT_IP_HEADER: string | undefined; | ||
| DD_TRACE_GOOGLE_GENAI_ENABLED: boolean; | ||
| DD_TRACE_GRAPHQL_COLLAPSE: boolean; | ||
| DD_TRACE_GRAPHQL_DEPTH: number; | ||
| DD_TRACE_GRAPHQL_ENABLED: boolean; | ||
@@ -932,2 +941,3 @@ DD_TRACE_GRAPHQL_ERROR_EXTENSIONS: string[]; | ||
| DD_TRACE_GRAPHQL_TOOLS_EXECUTOR_ENABLED: boolean; | ||
| DD_TRACE_GRAPHQL_VARIABLES: string[]; | ||
| DD_TRACE_GRAPHQL_YOGA_ENABLED: boolean; | ||
@@ -934,0 +944,0 @@ DD_TRACE_GRPC_ENABLED: boolean; |
| 'use strict' | ||
| const { EOL } = require('node:os') | ||
| const { EOL, platform } = require('node:os') | ||
@@ -74,3 +74,3 @@ // Load binding first to not import other modules if it throws | ||
| // Linux only, does not work on Mac. | ||
| const resolveMode = require('os').platform === 'linux' | ||
| const resolveMode = platform() === 'linux' | ||
| ? 'EnabledWithSymbolsInReceiver' | ||
@@ -77,0 +77,0 @@ : 'EnabledWithInprocessSymbols' |
| 'use strict' | ||
| const { channel } = require('dc-polyfill') | ||
| const BaseLLMObsPlugin = require('../base') | ||
| const { getModelProvider } = require('../../../../../datadog-plugin-ai/src/utils') | ||
| const CompositePlugin = require('../../../plugins/composite') | ||
| const DdTelemetryPlugin = require('./ddTelemetry') | ||
| const VercelAiTelemetryPlugin = require('./vercelTelemetry') | ||
| const toolCreationCh = channel('tracing:orchestrion:ai:tool:start') | ||
| const setAttributesCh = channel('dd-trace:vercel-ai:span:setAttributes') | ||
| const { MODEL_NAME, MODEL_PROVIDER, NAME } = require('../../constants/tags') | ||
| const { | ||
| getSpanTags, | ||
| getOperation, | ||
| getUsage, | ||
| getJsonStringValue, | ||
| getModelMetadata, | ||
| getGenerationMetadata, | ||
| getToolNameFromTags, | ||
| getToolCallResultContent, | ||
| getLlmObsSpanName, | ||
| getTelemetryMetadata, | ||
| } = require('./util') | ||
| /** | ||
| * @typedef {Record<string, unknown> & { description?: string, id?: string }} AvailableToolArgs | ||
| */ | ||
| /** | ||
| * @typedef {string | number | boolean | null | undefined | string[] | number[] | boolean[]} TagValue | ||
| * @typedef {Record<string, TagValue>} SpanTags | ||
| * | ||
| * @typedef {{ name?: string, description?: string }} ToolForModel | ||
| * | ||
| * @typedef {{ type: 'text' | 'reasoning' | 'redacted-reasoning', text?: string, data?: string }} TextPart | ||
| * @typedef {{ type: 'tool-call', toolName: string, toolCallId: string, args?: unknown, input?: unknown }} ToolCallPart | ||
| * @typedef {( | ||
| * { type: 'tool-result', toolCallId: string, output?: { type: string, value?: unknown }, result?: unknown } & | ||
| * Record<string, unknown> | ||
| * )} ToolResultPart | ||
| * | ||
| * @typedef {{ | ||
| * role: 'system', | ||
| * content: string | ||
| * } | { | ||
| * role: 'user', | ||
| * content: TextPart[] | ||
| * } | { | ||
| * role: 'assistant', | ||
| * content: Array<TextPart | ToolCallPart> | ||
| * } | { | ||
| * role: 'tool', | ||
| * content: ToolResultPart[] | ||
| * }} AiSdkMessage | ||
| */ | ||
| const SPAN_NAME_TO_KIND_MAPPING = { | ||
| // embeddings | ||
| embed: 'workflow', | ||
| embedMany: 'workflow', | ||
| doEmbed: 'embedding', | ||
| // object generation | ||
| generateObject: 'workflow', | ||
| streamObject: 'workflow', | ||
| // text generation | ||
| generateText: 'workflow', | ||
| streamText: 'workflow', | ||
| // llm operations | ||
| doGenerate: 'llm', | ||
| doStream: 'llm', | ||
| // tools | ||
| toolCall: 'tool', | ||
| } | ||
| class VercelAILLMObsPlugin extends BaseLLMObsPlugin { | ||
| static id = 'ai' | ||
| class VercelAILLMObsPlugin extends CompositePlugin { | ||
| static id = 'ai_llmobs' | ||
| static integration = 'ai' | ||
| static prefix = 'tracing:dd-trace:vercel-ai' | ||
| /** | ||
| * The available tools within the runtime scope of this integration. | ||
| * This essentially acts as a global registry for all tools made through the Vercel AI SDK. | ||
| * @type {Set<AvailableToolArgs>} | ||
| */ | ||
| #availableTools | ||
| /** | ||
| * A mapping of tool call IDs to tool names. | ||
| * This is used to map the tool call ID to the tool name for the output message. | ||
| * @type {Record<string, string>} | ||
| */ | ||
| #toolCallIdsToName | ||
| constructor (...args) { | ||
| super(...args) | ||
| this.#toolCallIdsToName = {} | ||
| this.#availableTools = new Set() | ||
| toolCreationCh.subscribe(ctx => { | ||
| const toolArgs = ctx.arguments | ||
| const tool = toolArgs[0] ?? {} | ||
| this.#availableTools.add(tool) | ||
| }) | ||
| setAttributesCh.subscribe(({ ctx, attributes }) => { | ||
| Object.assign(ctx.attributes, attributes) | ||
| }) | ||
| static plugins = { | ||
| dd: DdTelemetryPlugin, | ||
| ai: VercelAiTelemetryPlugin, | ||
| } | ||
| /** | ||
| * Does a best-effort attempt to find the right tool name for the given tool description. | ||
| * This is because the Vercel AI SDK does not tag tools by name properly, but | ||
| * rather by the index they were passed in. Tool names appear nowhere in the span tags. | ||
| * | ||
| * We use the tool description as the next best identifier for a tool. | ||
| * | ||
| * @param {string} toolName | ||
| * @param {string | undefined} toolDescription | ||
| * @returns {string | undefined} | ||
| */ | ||
| findToolName (toolName, toolDescription) { | ||
| if (Number.isNaN(Number.parseInt(toolName))) return toolName | ||
| for (const availableTool of this.#availableTools) { | ||
| const description = availableTool.description | ||
| if (description === toolDescription && availableTool.id) { | ||
| return availableTool.id | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * @override | ||
| */ | ||
| getLLMObsSpanRegisterOptions (ctx) { | ||
| const span = ctx.currentStore?.span | ||
| const operation = getOperation(span) | ||
| const kind = SPAN_NAME_TO_KIND_MAPPING[operation] | ||
| if (!kind) return | ||
| return { kind, name: getLlmObsSpanName(operation, ctx.attributes['ai.telemetry.functionId']) } | ||
| } | ||
| /** | ||
| * @override | ||
| */ | ||
| setLLMObsTags (ctx) { | ||
| const span = ctx.currentStore?.span | ||
| if (!span) return | ||
| const operation = getOperation(span) | ||
| const kind = SPAN_NAME_TO_KIND_MAPPING[operation] | ||
| if (!kind) return | ||
| const tags = getSpanTags(ctx) | ||
| if (['embedding', 'llm'].includes(kind)) { | ||
| this._tagger._setTag(span, MODEL_NAME, tags['ai.model.id']) | ||
| this._tagger._setTag(span, MODEL_PROVIDER, getModelProvider(tags)) | ||
| } | ||
| switch (operation) { | ||
| case 'embed': | ||
| case 'embedMany': | ||
| this.setEmbeddingWorkflowTags(span, tags) | ||
| break | ||
| case 'doEmbed': | ||
| this.setEmbeddingTags(span, tags) | ||
| break | ||
| case 'generateObject': | ||
| case 'streamObject': | ||
| this.setObjectGenerationTags(span, tags) | ||
| break | ||
| case 'generateText': | ||
| case 'streamText': | ||
| this.setTextGenerationTags(span, tags) | ||
| break | ||
| case 'doGenerate': | ||
| case 'doStream': | ||
| this.setLLMOperationTags(span, tags) | ||
| break | ||
| case 'toolCall': | ||
| this.setToolTags(span, tags) | ||
| break | ||
| default: | ||
| break | ||
| } | ||
| } | ||
| setEmbeddingWorkflowTags (span, tags) { | ||
| const inputs = tags['ai.value'] ?? tags['ai.values'] | ||
| const parsedInputs = Array.isArray(inputs) | ||
| ? inputs.map(input => getJsonStringValue(input, '')) | ||
| : getJsonStringValue(inputs, '') | ||
| const embeddingsOutput = tags['ai.embedding'] ?? tags['ai.embeddings'] | ||
| const isSingleEmbedding = !Array.isArray(embeddingsOutput) | ||
| const numberOfEmbeddings = isSingleEmbedding ? 1 : embeddingsOutput.length | ||
| const embeddingsLength = getJsonStringValue(isSingleEmbedding ? embeddingsOutput : embeddingsOutput?.[0], []).length | ||
| const output = `[${numberOfEmbeddings} embedding(s) returned with size ${embeddingsLength}]` | ||
| this._tagger.tagTextIO(span, parsedInputs, output) | ||
| const metadata = getGenerationMetadata(tags) | ||
| this._tagger.tagMetadata(span, metadata) | ||
| } | ||
| setEmbeddingTags (span, tags) { | ||
| const inputs = tags['ai.values'] | ||
| if (!Array.isArray(inputs)) return | ||
| const parsedInputs = inputs.map(input => getJsonStringValue(input, '')) | ||
| const embeddingsOutput = tags['ai.embeddings'] | ||
| const numberOfEmbeddings = embeddingsOutput?.length | ||
| const embeddingsLength = getJsonStringValue(embeddingsOutput?.[0], []).length | ||
| const output = `[${numberOfEmbeddings} embedding(s) returned with size ${embeddingsLength}]` | ||
| this._tagger.tagEmbeddingIO(span, parsedInputs, output) | ||
| const metadata = getTelemetryMetadata(tags) | ||
| this._tagger.tagMetadata(span, metadata) | ||
| const usage = tags['ai.usage.tokens'] | ||
| this._tagger.tagMetrics(span, { | ||
| inputTokens: usage, | ||
| totalTokens: usage, | ||
| }) | ||
| } | ||
| setObjectGenerationTags (span, tags) { | ||
| const promptInfo = getJsonStringValue(tags['ai.prompt'], {}) | ||
| const lastUserPrompt = | ||
| promptInfo.prompt ?? | ||
| promptInfo.messages.reverse().find(message => message.role === 'user')?.content | ||
| const prompt = Array.isArray(lastUserPrompt) ? lastUserPrompt.map(part => part.text ?? '').join('') : lastUserPrompt | ||
| const output = tags['ai.response.object'] | ||
| this._tagger.tagTextIO(span, prompt, output) | ||
| const metadata = getGenerationMetadata(tags) | ||
| metadata.schema = getJsonStringValue(tags['ai.schema'], {}) | ||
| this._tagger.tagMetadata(span, metadata) | ||
| } | ||
| setTextGenerationTags (span, tags) { | ||
| const promptInfo = getJsonStringValue(tags['ai.prompt'], {}) | ||
| const lastUserPrompt = | ||
| promptInfo.prompt ?? | ||
| promptInfo.messages.reverse().find(message => message.role === 'user')?.content | ||
| const prompt = Array.isArray(lastUserPrompt) ? lastUserPrompt.map(part => part.text ?? '').join('') : lastUserPrompt | ||
| const output = tags['ai.response.text'] | ||
| this._tagger.tagTextIO(span, prompt, output) | ||
| const metadata = getGenerationMetadata(tags) | ||
| this._tagger.tagMetadata(span, metadata) | ||
| } | ||
| /** | ||
| * @param {import('../../../opentracing/span')} span | ||
| * @param {SpanTags} tags | ||
| */ | ||
| setLLMOperationTags (span, tags) { | ||
| const toolsForModel = tags['ai.prompt.tools']?.map(getJsonStringValue) | ||
| const inputMessages = getJsonStringValue(tags['ai.prompt.messages'], []) | ||
| const parsedInputMessages = [] | ||
| for (const message of inputMessages) { | ||
| const formattedMessages = this.formatMessage(message, toolsForModel) | ||
| parsedInputMessages.push(...formattedMessages) | ||
| } | ||
| const outputMessage = this.formatOutputMessage(tags, toolsForModel) | ||
| this._tagger.tagLLMIO(span, parsedInputMessages, outputMessage) | ||
| const metadata = getModelMetadata(tags) | ||
| this._tagger.tagMetadata(span, metadata) | ||
| const usage = getUsage(tags) | ||
| this._tagger.tagMetrics(span, usage) | ||
| } | ||
| setToolTags (span, tags) { | ||
| const toolCallId = tags['ai.toolCall.id'] | ||
| const name = getToolNameFromTags(tags) ?? this.#toolCallIdsToName[toolCallId] | ||
| if (name) this._tagger._setTag(span, NAME, name) | ||
| const input = tags['ai.toolCall.args'] | ||
| const output = tags['ai.toolCall.result'] | ||
| this._tagger.tagTextIO(span, input, output) | ||
| } | ||
| formatOutputMessage (tags, toolsForModel) { | ||
| const outputMessageText = tags['ai.response.text'] ?? tags['ai.response.object'] | ||
| const outputMessageToolCalls = getJsonStringValue(tags['ai.response.toolCalls'], []) | ||
| const formattedToolCalls = [] | ||
| for (const toolCall of outputMessageToolCalls) { | ||
| const toolArgs = toolCall.args ?? toolCall.input | ||
| const toolCallArgs = typeof toolArgs === 'string' ? getJsonStringValue(toolArgs, {}) : toolArgs | ||
| const toolDescription = toolsForModel?.find(tool => toolCall.toolName === tool.name)?.description | ||
| const name = this.findToolName(toolCall.toolName, toolDescription) | ||
| this.#toolCallIdsToName[toolCall.toolCallId] = name | ||
| formattedToolCalls.push({ | ||
| arguments: toolCallArgs, | ||
| name, | ||
| toolId: toolCall.toolCallId, | ||
| type: toolCall.toolCallType ?? 'function', | ||
| }) | ||
| } | ||
| return { | ||
| role: 'assistant', | ||
| content: outputMessageText, | ||
| toolCalls: formattedToolCalls, | ||
| } | ||
| } | ||
| /** | ||
| * Returns a list of formatted messages from a message object. | ||
| * Most of these will just be one entry, but in the case of a "tool" role, | ||
| * it is possible to have multiple tool call results in a single message that we | ||
| * need to split into multiple messages. | ||
| * | ||
| * @param {AiSdkMessage} message | ||
| * @param {ToolForModel[] | null | undefined} toolsForModel | ||
| * @returns {Array<{role: string, content: string, toolId?: string, | ||
| * toolCalls?: Array<{arguments: string, name: string, toolId: string, type: string}>}>} | ||
| */ | ||
| formatMessage (message, toolsForModel) { | ||
| const { role, content } = message | ||
| if (role === 'system') { | ||
| return [{ role, content }] | ||
| } else if (role === 'user') { | ||
| let finalContent = '' | ||
| for (const part of content) { | ||
| const { type } = part | ||
| if (type === 'text') { | ||
| finalContent += part.text | ||
| } | ||
| } | ||
| return [{ role, content: finalContent }] | ||
| } else if (role === 'assistant') { | ||
| const toolCalls = [] | ||
| let finalContent = '' | ||
| for (const part of content) { | ||
| const { type } = part | ||
| // TODO(sabrenner): do we want to include reasoning? | ||
| if (['text', 'reasoning', 'redacted-reasoning'].includes(type)) { | ||
| finalContent += part.text ?? part.data | ||
| } else if (type === 'tool-call') { | ||
| const toolDescription = toolsForModel?.find(tool => part.toolName === tool.name)?.description | ||
| const name = this.findToolName(part.toolName, toolDescription) | ||
| toolCalls.push({ | ||
| arguments: part.args ?? part.input, | ||
| name, | ||
| toolId: part.toolCallId, | ||
| type: 'function', | ||
| }) | ||
| } | ||
| } | ||
| const finalMessage = { | ||
| role, | ||
| content: finalContent, | ||
| } | ||
| if (toolCalls.length) { | ||
| finalMessage.toolCalls = toolCalls.length ? toolCalls : undefined | ||
| } | ||
| return [finalMessage] | ||
| } else if (role === 'tool') { | ||
| const finalMessages = [] | ||
| for (const part of content) { | ||
| if (part.type === 'tool-result') { | ||
| const safeResult = getToolCallResultContent(part) | ||
| finalMessages.push({ | ||
| role, | ||
| content: safeResult, | ||
| toolId: part.toolCallId, | ||
| }) | ||
| } | ||
| } | ||
| return finalMessages | ||
| } | ||
| return [] | ||
| } | ||
| } | ||
| module.exports = VercelAILLMObsPlugin |
@@ -263,2 +263,28 @@ 'use strict' | ||
| /** | ||
| * Get the generation metadata from the span tags (maxSteps, maxRetries, etc.) | ||
| * Additionally, set telemetry metadata from manual telemetry tags. | ||
| * @param {Record<string, unknown>} event | ||
| * @returns {Record<string, unknown> | null} | ||
| */ | ||
| function getGenerationMetadataFromEvent (event) { | ||
| /** @type {Record<string, unknown>} */ | ||
| const metadata = {} | ||
| for (const [key, value] of Object.entries(event)) { | ||
| const transformedKey = key.replaceAll(/[A-Z]/g, letter => '_' + letter.toLowerCase()) | ||
| if (!MODEL_METADATA_KEYS.has(transformedKey)) { | ||
| if (key === 'runtimeContext') { // custom telemetry metadata | ||
| Object.assign(metadata, value) | ||
| } | ||
| continue | ||
| } | ||
| metadata[transformedKey] = value | ||
| } | ||
| return Object.keys(metadata).length ? metadata : null | ||
| } | ||
| /** | ||
| * Get the tool name from the span tags. | ||
@@ -352,2 +378,3 @@ * If the tool name is a parsable number, or is not found, null is returned. | ||
| getTelemetryMetadata, | ||
| getGenerationMetadataFromEvent, | ||
| } |
@@ -155,2 +155,7 @@ 'use strict' | ||
| tags: { | ||
| // Apply global/resource tags (DD_TAGS, OTEL_RESOURCE_ATTRIBUTES) as | ||
| // defaults, mirroring the native path in opentracing/tracer.js. The | ||
| // explicit service/resource/span.kind below take precedence, and any | ||
| // user-set OTel attributes applied via setAttributes() still win. | ||
| ..._tracer._config.tags, | ||
| [SERVICE_NAME]: _tracer._service, | ||
@@ -157,0 +162,0 @@ [RESOURCE_NAME]: spanName, |
@@ -172,2 +172,6 @@ 'use strict' | ||
| DD_TRACE_OTEL_SEMANTICS_ENABLED, | ||
| DD_TRACE_GRAPHQL_COLLAPSE, | ||
| DD_TRACE_GRAPHQL_DEPTH, | ||
| DD_TRACE_GRAPHQL_VARIABLES, | ||
| DD_TRACE_GRAPHQL_ERROR_EXTENSIONS, | ||
| DD_TEST_SESSION_NAME, | ||
@@ -229,4 +233,16 @@ DD_AGENTLESS_LOG_SUBMISSION_ENABLED, | ||
| // The graphql `DD_TRACE_GRAPHQL_*` options are global on purpose: they feed | ||
| // the plugin config as a base that a programmatic `tracer.use('graphql', …)` | ||
| // overrides, and stay on the Config singleton so remote config and config | ||
| // telemetry observe them. Forwarded only for graphql so other plugins do not | ||
| // carry keys they ignore. The plugin-facing names drop the prefix. | ||
| if (name === 'graphql') { | ||
| sharedConfig.collapse = DD_TRACE_GRAPHQL_COLLAPSE | ||
| sharedConfig.depth = DD_TRACE_GRAPHQL_DEPTH | ||
| sharedConfig.variables = DD_TRACE_GRAPHQL_VARIABLES | ||
| sharedConfig.errorExtensions = DD_TRACE_GRAPHQL_ERROR_EXTENSIONS | ||
| } | ||
| return sharedConfig | ||
| } | ||
| } |
| 'use strict' | ||
| // Capture real timers at module load time, before any test can install fake timers. | ||
| const realSetTimeout = setTimeout | ||
| const realClearTimeout = clearTimeout | ||
| const { threadId } = require('node:worker_threads') | ||
| const { storage } = require('../../../datadog-core') | ||
@@ -15,2 +21,3 @@ const { COMPONENT } = require('../constants') | ||
| const { DD_MAJOR } = require('../../../../version') | ||
| const { version: tracerVersion } = require('../../../../package.json') | ||
| const id = require('../id') | ||
@@ -88,2 +95,5 @@ const { OS_VERSION, OS_PLATFORM, OS_ARCHITECTURE, RUNTIME_NAME, RUNTIME_VERSION } = require('./util/env') | ||
| const legacyStorage = storage('legacy') | ||
| const DI_OPERATION_TIMEOUT_MS = 2000 | ||
| const DI_LOGGER_THREAD_ID = threadId === 0 ? `pid:${process.pid}` : `pid:${process.pid};tid:${threadId}` | ||
| const DI_LOGGER_THREAD_NAME = threadId === 0 ? 'MainThread' : `WorkerThread:${threadId}` | ||
@@ -113,2 +123,14 @@ const FRAMEWORK_TO_TRIMMED_COMMAND = { | ||
| function withTimeout (promise, timeoutMs) { | ||
| return new Promise(resolve => { | ||
| const timeoutId = realSetTimeout(resolve, timeoutMs) | ||
| const done = () => { | ||
| realClearTimeout(timeoutId) | ||
| resolve() | ||
| } | ||
| promise.then(done, done) | ||
| }) | ||
| } | ||
| function setItrSkippingEnabledTagFromLibraryConfig (plugin, frameworkVersion) { | ||
@@ -149,2 +171,4 @@ const libraryCapabilitiesTags = getDefaultLibraryCapabilitiesTags(plugin.constructor.id, frameworkVersion) | ||
| this.fileLineToProbeId = new Map() | ||
| this.diBreakpointHitPromise = undefined | ||
| this.diBreakpointHitResolvers = [] | ||
| this.rootDir = process.cwd() // fallback in case :session:start events are not emitted | ||
@@ -171,6 +195,2 @@ this._testSuiteSpansByTestSuite = new Map() | ||
| const requestErrorTags = this.testSessionSpan | ||
| ? getSessionRequestErrorTags(this.testSessionSpan) | ||
| : Object.fromEntries(this._pendingRequestErrorTags.map(({ tag, value }) => [tag, value])) | ||
| const libraryCapabilitiesTags = this.getLibraryCapabilitiesTags(frameworkVersion, ctx) | ||
@@ -183,3 +203,9 @@ const metadataTags = { | ||
| this.tracer._exporter.addMetadataTags(metadataTags) | ||
| onDone({ err, libraryConfig, repositoryRoot: this.repositoryRoot, requestErrorTags }) | ||
| onDone({ | ||
| err, | ||
| isTestDynamicInstrumentationEnabled: this.config.isTestDynamicInstrumentationEnabled, | ||
| libraryConfig, | ||
| repositoryRoot: this.repositoryRoot, | ||
| requestErrorTags: this._getCurrentRequestErrorTags(), | ||
| }) | ||
| }) | ||
@@ -316,3 +342,3 @@ }) | ||
| } | ||
| onDone({ err, knownTests }) | ||
| onDone({ err, knownTests, requestErrorTags: this._getCurrentRequestErrorTags() }) | ||
| }) | ||
@@ -337,3 +363,3 @@ }) | ||
| } | ||
| onDone({ err, testManagementTests }) | ||
| onDone({ err, testManagementTests, requestErrorTags: this._getCurrentRequestErrorTags() }) | ||
| }) | ||
@@ -471,2 +497,14 @@ }) | ||
| /** | ||
| * Returns the current request error tags, including tags queued before session creation. | ||
| * | ||
| * @returns {Record<string, string>} | ||
| */ | ||
| _getCurrentRequestErrorTags () { | ||
| if (this.testSessionSpan) { | ||
| return getSessionRequestErrorTags(this.testSessionSpan) | ||
| } | ||
| return Object.fromEntries(this._pendingRequestErrorTags.map(({ tag, value }) => [tag, value])) | ||
| } | ||
| /** | ||
| * Updates repository-root-dependent state when a worker receives the root from | ||
@@ -788,2 +826,7 @@ * the coordinator process after plugin configuration. | ||
| onDiBreakpointHit ({ snapshot }) { | ||
| for (const resolve of this.diBreakpointHitResolvers) { | ||
| resolve() | ||
| } | ||
| this.diBreakpointHitResolvers.length = 0 | ||
| if (!this.activeTestSpan || this.activeTestSpan.context()._isFinished) { | ||
@@ -812,4 +855,13 @@ // This is unexpected and is caused by a race condition. | ||
| const activeTestSpanContext = this.activeTestSpan.context() | ||
| const topStackFrame = snapshot.stack?.[0] | ||
| this.tracer._exporter.exportDiLogs(this.testEnvironmentMetadata, { | ||
| message: '', | ||
| logger: { | ||
| name: snapshot.probe.location.file, | ||
| method: topStackFrame?.function || '', | ||
| version: tracerVersion, | ||
| thread_id: DI_LOGGER_THREAD_ID, | ||
| thread_name: DI_LOGGER_THREAD_NAME, | ||
| }, | ||
| debugger: { snapshot }, | ||
@@ -823,2 +875,90 @@ dd: { | ||
| /** | ||
| * Wait for a Dynamic Instrumentation operation without blocking test framework progress forever. | ||
| * | ||
| * @param {Promise<void>} promise - Dynamic Instrumentation operation promise. | ||
| * @returns {Promise<void>} | ||
| */ | ||
| waitForDiOperation (promise) { | ||
| return withTimeout(promise, DI_OPERATION_TIMEOUT_MS) | ||
| } | ||
| /** | ||
| * Resolve any prepared breakpoint-hit wait when no caller still needs it. | ||
| */ | ||
| cancelDiBreakpointHitWait () { | ||
| for (const resolve of this.diBreakpointHitResolvers) { | ||
| resolve() | ||
| } | ||
| this.diBreakpointHitResolvers.length = 0 | ||
| } | ||
| /** | ||
| * Wait for a prepared breakpoint hit before resolving any unused waiters. | ||
| * | ||
| * @returns {Promise<void>} | ||
| */ | ||
| waitForPreparedDiBreakpointHit () { | ||
| if (!this.diBreakpointHitPromise) { | ||
| this.cancelDiBreakpointHitWait() | ||
| return Promise.resolve() | ||
| } | ||
| return this.waitForDiOperation(this.diBreakpointHitPromise).then( | ||
| () => this.cancelDiBreakpointHitWait(), | ||
| () => this.cancelDiBreakpointHitWait() | ||
| ) | ||
| } | ||
| /** | ||
| * Prepare a wait for the next breakpoint hit before the retried test starts. | ||
| * | ||
| * @returns {Promise<void>} | ||
| */ | ||
| prepareDiBreakpointHitWait () { | ||
| if (!this.di) return Promise.resolve() | ||
| let resolveHit | ||
| const hitPromise = new Promise(resolve => { | ||
| resolveHit = resolve | ||
| this.diBreakpointHitResolvers.push(resolve) | ||
| }) | ||
| const preparedPromise = hitPromise.finally(() => { | ||
| const resolverIndex = this.diBreakpointHitResolvers.indexOf(resolveHit) | ||
| if (resolverIndex !== -1) { | ||
| this.diBreakpointHitResolvers.splice(resolverIndex, 1) | ||
| } | ||
| if (this.diBreakpointHitPromise === preparedPromise) { | ||
| this.diBreakpointHitPromise = undefined | ||
| } | ||
| }) | ||
| this.diBreakpointHitPromise = preparedPromise | ||
| return this.diBreakpointHitPromise | ||
| } | ||
| /** | ||
| * Wait until the DI worker has posted any breakpoint hits it was already processing. | ||
| * | ||
| * @returns {Promise<void>} | ||
| */ | ||
| waitForDiBreakpointHits () { | ||
| if (!this.di) return Promise.resolve() | ||
| if (this.diBreakpointHitPromise) return this.waitForDiOperation(this.diBreakpointHitPromise) | ||
| return this.waitForInFlightDiBreakpointHits() | ||
| } | ||
| /** | ||
| * Wait until the DI worker has posted breakpoint hits it was already processing. | ||
| * | ||
| * @returns {Promise<void>} | ||
| */ | ||
| waitForInFlightDiBreakpointHits () { | ||
| if (!this.di) return Promise.resolve() | ||
| return this.waitForDiOperation(this.di.waitForInFlightBreakpointHits()) | ||
| } | ||
| removeAllDiProbes () { | ||
@@ -825,0 +965,0 @@ if (this.fileLineToProbeId.size === 0) { |
| 'use strict' | ||
| const plugins = { | ||
| get '@anthropic-ai/claude-agent-sdk' () { return require('../../../datadog-plugin-claude-agent-sdk/src') }, | ||
| get '@anthropic-ai/sdk' () { return require('../../../datadog-plugin-anthropic/src') }, | ||
@@ -49,2 +50,3 @@ get '@apollo/gateway' () { return require('../../../datadog-plugin-apollo/src') }, | ||
| get child_process () { return require('../../../datadog-plugin-child_process/src') }, | ||
| get 'claude-agent-sdk' () { return require('../../../datadog-plugin-claude-agent-sdk/src') }, | ||
| get connect () { return require('../../../datadog-plugin-connect/src') }, | ||
@@ -51,0 +53,0 @@ get couchbase () { return require('../../../datadog-plugin-couchbase/src') }, |
@@ -45,2 +45,15 @@ 'use strict' | ||
| const start = perf.now() | ||
| // Ensure the callback runs at most once per request. The 'timeout' handler | ||
| // below can fire after a response was already received (e.g. on a lingering | ||
| // keep-alive socket, once it goes idle); without this guard that would | ||
| // invoke the callback a second time and corrupt the retry accounting in | ||
| // AgentExporter.export. | ||
| let settled = false | ||
| const done = (err, res) => { | ||
| if (settled) return | ||
| settled = true | ||
| callback(err, res) | ||
| } | ||
| const req = request(options, res => { | ||
@@ -53,5 +66,5 @@ durationDistribution.track(perf.now() - start) | ||
| error.status = res.statusCode | ||
| callback(error) | ||
| done(error) | ||
| } else { | ||
| callback(null, res) | ||
| done(null, res) | ||
| } | ||
@@ -62,4 +75,16 @@ }) | ||
| networkErrorCounter.inc() | ||
| callback(err) | ||
| done(err) | ||
| }) | ||
| // `options.timeout` sets the socket's idle timeout, which only emits a | ||
| // 'timeout' event — per the Node docs it does NOT abort the request. Without | ||
| // destroying the socket here, a stalled upload hangs indefinitely: no | ||
| // 'error' fires, so the retry/backoff in AgentExporter.export never runs and | ||
| // the export promise never settles. Destroying the request makes the | ||
| // 'error' handler above run, which drives the retry (see the trace exporter | ||
| // in ../common/request.js, which likewise aborts the request on timeout). | ||
| req.on('timeout', () => { | ||
| req.destroy(new Error(`Profiling agent export timed out after ${options.timeout}ms`)) | ||
| }) | ||
| if (form) { | ||
@@ -66,0 +91,0 @@ sizeDistribution.track(form.size()) |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 4 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
5838069
2.2%1019
1.29%127954
2.69%57
5.56%124
2.48%Updated