🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

dd-trace

Package Overview
Dependencies
Maintainers
1
Versions
710
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

dd-trace - npm Package Compare versions

Comparing version
5.111.0
to
5.112.0
+587
packages/datadog-instrumentations/src/claude-agent-sdk.js
'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 }
+1
-2

@@ -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']"

{
"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