dd-trace
Version:
Datadog APM tracing client for JavaScript
381 lines (306 loc) • 10.4 kB
JavaScript
'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',
embedMany: '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':
case 'embedMany':
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
// works for both embed and embedMany, either single string or array of strings
const input = event.value
if (!result) {
this._tagger.tagEmbeddingIO(span, input)
return
}
// embed returns single embedding, embedMany returns an array of embeddings
const singleEmbedding = result.embedding
const multiEmbedding = result.embeddings
let embeddingTextResult = ''
if (singleEmbedding) {
embeddingTextResult = `[1 embedding(s) returned with size ${singleEmbedding.length}]`
} else if (Array.isArray(multiEmbedding)) {
embeddingTextResult = `[${multiEmbedding.length} embedding(s) returned with size ${multiEmbedding[0]?.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