dd-trace
Version:
Datadog APM tracing client for JavaScript
503 lines (432 loc) • 17 kB
JavaScript
'use strict'
const LLMObsPlugin = require('../base')
const {
PROMPT_TRACKING_INSTRUMENTATION_METHOD,
PROMPT_MULTIMODAL,
INSTRUMENTATION_METHOD_AUTO
} = require('../../constants/tags')
const {
extractChatTemplateFromInstructions,
normalizePromptVariables,
extractTextFromContentItem,
hasMultimodalInputs
} = require('./utils')
const allowedParamKeys = new Set([
'max_output_tokens',
'temperature',
'stream',
'reasoning'
])
function isIterable (obj) {
if (obj == null) {
return false
}
return typeof obj[Symbol.iterator] === 'function'
}
class OpenAiLLMObsPlugin extends LLMObsPlugin {
static id = 'openai'
static integration = 'openai'
static prefix = 'tracing:apm:openai:request'
getLLMObsSpanRegisterOptions (ctx) {
const resource = ctx.methodName
const methodName = gateResource(normalizeOpenAIResourceName(resource))
if (!methodName) return // we will not trace all openai methods for llmobs
const inputs = ctx.args[0] // completion, chat completion, embeddings, and responses take one argument
const operation = getOperation(methodName)
const kind = operation === 'embedding' ? 'embedding' : 'llm'
const { modelProvider, client } = this._getModelProviderAndClient(ctx.basePath)
const name = `${client}.${methodName}`
return {
modelProvider,
modelName: inputs.model,
kind,
name
}
}
setLLMObsTags (ctx) {
const span = ctx.currentStore?.span
const resource = ctx.methodName
const methodName = gateResource(normalizeOpenAIResourceName(resource))
if (!methodName) return // we will not trace all openai methods for llmobs
const inputs = ctx.args[0] // completion, chat completion, and embeddings take one argument
const response = ctx.result?.data // no result if error
const error = !!span.context()._tags.error
const operation = getOperation(methodName)
if (operation === 'completion') {
this._tagCompletion(span, inputs, response, error)
} else if (operation === 'chat') {
this._tagChatCompletion(span, inputs, response, error)
} else if (operation === 'embedding') {
this._tagEmbedding(span, inputs, response, error)
} else if (operation === 'response') {
this.#tagResponse(span, inputs, response, error)
}
if (!error) {
const metrics = this._extractMetrics(response)
this._tagger.tagMetrics(span, metrics)
const responseModel = response.model
if (responseModel) {
// override the model name with the response model (more accurate)
this._tagger.tagModelName(span, responseModel)
}
}
}
_getModelProviderAndClient (baseUrl = '') {
if (baseUrl.includes('azure')) {
return { modelProvider: 'azure_openai', client: 'AzureOpenAI' }
} else if (baseUrl.includes('deepseek')) {
return { modelProvider: 'deepseek', client: 'DeepSeek' }
}
return { modelProvider: 'openai', client: 'OpenAI' }
}
_extractMetrics (response) {
const metrics = {}
const tokenUsage = response.usage
if (tokenUsage) {
// Responses API uses input_tokens, Chat/Completions use prompt_tokens
const inputTokens = tokenUsage.input_tokens ?? tokenUsage.prompt_tokens ?? 0
if (inputTokens !== undefined) metrics.inputTokens = inputTokens
// Responses API uses output_tokens, Chat/Completions use completion_tokens
const outputTokens = tokenUsage.output_tokens ?? tokenUsage.completion_tokens ?? 0
if (outputTokens !== undefined) metrics.outputTokens = outputTokens
const totalTokens = tokenUsage.total_tokens || (inputTokens + outputTokens)
if (totalTokens !== undefined) metrics.totalTokens = totalTokens
// Cache tokens - Responses API uses input_tokens_details, Chat/Completions use prompt_tokens_details
// For Responses API, always include cache tokens (even if 0)
// For Chat API, only include if > 0
if (tokenUsage.input_tokens_details) {
// Responses API - always include
const cacheReadTokens = tokenUsage.input_tokens_details.cached_tokens
if (cacheReadTokens !== undefined) metrics.cacheReadTokens = cacheReadTokens
} else if (tokenUsage.prompt_tokens_details) {
// Chat/Completions API - only include if > 0
const cacheReadTokens = tokenUsage.prompt_tokens_details.cached_tokens
if (cacheReadTokens != null) {
metrics.cacheReadTokens = cacheReadTokens
}
}
// Reasoning tokens - Responses API returns `output_tokens_details`, `completion_tokens_details`
const reasoningOutputObject = tokenUsage.output_tokens_details ?? tokenUsage.completion_tokens_details
const reasoningOutputTokens = reasoningOutputObject?.reasoning_tokens ?? 0
if (reasoningOutputTokens !== undefined) metrics.reasoningOutputTokens = reasoningOutputTokens
}
return metrics
}
_tagEmbedding (span, inputs, response, error) {
const { model, ...parameters } = inputs
const metadata = {
encoding_format: parameters.encoding_format || 'float'
}
if (inputs.dimensions) metadata.dimensions = inputs.dimensions
this._tagger.tagMetadata(span, metadata)
let embeddingInputs = inputs.input
if (!Array.isArray(embeddingInputs)) embeddingInputs = [embeddingInputs]
const embeddingInput = embeddingInputs.map(input => ({ text: input }))
if (error) {
this._tagger.tagEmbeddingIO(span, embeddingInput)
return
}
const float = Array.isArray(response.data[0].embedding)
let embeddingOutput
if (float) {
const embeddingDim = response.data[0].embedding.length
embeddingOutput = `[${response.data.length} embedding(s) returned with size ${embeddingDim}]`
} else {
embeddingOutput = `[${response.data.length} embedding(s) returned]`
}
this._tagger.tagEmbeddingIO(span, embeddingInput, embeddingOutput)
}
_tagCompletion (span, inputs, response, error) {
let { prompt, model, ...parameters } = inputs
if (!Array.isArray(prompt)) prompt = [prompt]
const completionInput = prompt.map(p => ({ content: p }))
const completionOutput = error ? [{ content: '' }] : response.choices.map(choice => ({ content: choice.text }))
this._tagger.tagLLMIO(span, completionInput, completionOutput)
this._tagger.tagMetadata(span, parameters)
}
_tagChatCompletion (span, inputs, response, error) {
const { messages, model, ...parameters } = inputs
const metadata = Object.entries(parameters).reduce((obj, [key, value]) => {
if (!['tools', 'functions'].includes(key)) {
obj[key] = value
}
return obj
}, {})
this._tagger.tagMetadata(span, metadata)
if (error) {
this._tagger.tagLLMIO(span, messages, [{ content: '' }])
return
}
const outputMessages = []
const { choices } = response
if (!isIterable(choices)) {
this._tagger.tagLLMIO(span, messages, [{ content: '' }])
return
}
for (const choice of choices) {
const message = choice.message || choice.delta
const content = message.content || ''
const role = message.role
if (message.function_call) {
const functionCallInfo = {
name: message.function_call.name,
arguments: JSON.parse(message.function_call.arguments)
}
outputMessages.push({ content, role, toolCalls: [functionCallInfo] })
} else if (message.tool_calls) {
const toolCallsInfo = []
for (const toolCall of message.tool_calls) {
const toolCallInfo = {
arguments: JSON.parse(toolCall.function.arguments),
name: toolCall.function.name,
toolId: toolCall.id,
type: toolCall.type
}
toolCallsInfo.push(toolCallInfo)
}
outputMessages.push({ content, role, toolCalls: toolCallsInfo })
} else {
outputMessages.push({ content, role })
}
}
this._tagger.tagLLMIO(span, messages, outputMessages)
}
#tagResponse (span, inputs, response, error) {
// Tag metadata - use allowlist approach for request parameters
const { model, ...parameters } = inputs
let input = inputs.input
// Create input messages
const inputMessages = []
// Add system message if instructions exist
if (inputs.instructions) {
inputMessages.push({ role: 'system', content: inputs.instructions })
}
// For reusable prompts, use response.instructions if no explicit input is provided
if (!input && inputs.prompt && response?.instructions) {
input = response.instructions
}
// Handle input - can be string or array of mixed messages
if (Array.isArray(input)) {
for (const item of input) {
if (item.type === 'message') {
// Handle instruction messages (from response.instructions for reusable prompts)
const role = item.role
if (!role) continue
let content = ''
if (Array.isArray(item.content)) {
const textParts = item.content
.map(extractTextFromContentItem)
.filter(Boolean)
content = textParts.join('')
} else if (typeof item.content === 'string') {
content = item.content
}
if (content) {
inputMessages.push({ role, content })
}
} else if (item.type === 'function_call') {
// Function call: convert to message with tool_calls
// Parse arguments if it's a JSON string
let parsedArgs = item.arguments
if (typeof parsedArgs === 'string') {
try {
parsedArgs = JSON.parse(parsedArgs)
} catch {
parsedArgs = {}
}
}
inputMessages.push({
role: 'assistant',
toolCalls: [{
toolId: item.call_id,
name: item.name,
arguments: parsedArgs,
type: item.type
}]
})
} else if (item.type === 'function_call_output') {
// Function output: convert to user message with tool_results
inputMessages.push({
role: 'user',
toolResults: [{
toolId: item.call_id,
result: item.output,
name: item.name || '',
type: item.type
}]
})
} else if (item.role && item.content) {
// Regular message
inputMessages.push({ role: item.role, content: item.content })
}
}
} else {
// Simple string input
inputMessages.push({ role: 'user', content: input })
}
const inputMetadata = Object.entries(parameters).reduce((obj, [key, value]) => {
if (allowedParamKeys.has(key)) {
obj[key] = value
}
return obj
}, {})
this._tagger.tagMetadata(span, inputMetadata)
if (error) {
this._tagger.tagLLMIO(span, inputMessages, [{ content: '' }])
return
}
// Create output messages
const outputMessages = []
// Handle output - can be string (streaming) or array of message objects (non-streaming)
if (typeof response.output === 'string') {
// Simple text output (streaming)
outputMessages.push({ role: 'assistant', content: response.output })
} else if (Array.isArray(response.output)) {
// Array output - process all items to extract reasoning, messages, and tool calls
// Non-streaming: array of items (messages, function_calls, or reasoning)
for (const item of response.output) {
// Handle reasoning type (reasoning responses)
if (item.type === 'reasoning') {
outputMessages.push({
role: 'reasoning',
content: JSON.stringify({
summary: item.summary ?? [],
encrypted_content: item.encrypted_content ?? null,
id: item.id ?? ''
})
})
} else if (item.type === 'function_call') {
// Handle function_call type (responses API tool calls)
let args = item.arguments
// Parse arguments if it's a JSON string
if (typeof args === 'string') {
try {
args = JSON.parse(args)
} catch {
args = {}
}
}
outputMessages.push({
role: 'assistant',
toolCalls: [{
toolId: item.call_id,
name: item.name,
arguments: args,
type: item.type
}]
})
} else {
// Handle regular message objects
const outputMsg = { role: item.role || 'assistant', content: '' }
// Extract content from message
if (Array.isArray(item.content)) {
// Content is array of content parts
// For responses API, text content has type 'output_text', not 'text'
const textParts = item.content
.filter(c => c.type === 'output_text')
.map(c => c.text)
outputMsg.content = textParts.join('')
} else if (typeof item.content === 'string') {
outputMsg.content = item.content
}
// Extract tool calls if present in message.tool_calls
if (Array.isArray(item.tool_calls)) {
outputMsg.toolCalls = item.tool_calls.map(tc => {
let args = tc.function?.arguments || tc.arguments
// Parse arguments if it's a JSON string
if (typeof args === 'string') {
try {
args = JSON.parse(args)
} catch {
args = {}
}
}
return {
toolId: tc.id,
name: tc.function?.name || tc.name,
arguments: args,
type: tc.type || 'function_call'
}
})
}
outputMessages.push(outputMsg)
}
}
} else if (response.output_text) {
// Fallback: use output_text if available (for simple non-streaming responses without reasoning/tools)
outputMessages.push({ role: 'assistant', content: response.output_text })
} else {
// No output
outputMessages.push({ role: 'assistant', content: '' })
}
this._tagger.tagLLMIO(span, inputMessages, outputMessages)
// Handle prompt tracking for reusable prompts
if (inputs.prompt && response?.prompt) {
const { id, version } = response.prompt // ResponsePrompt
// TODO: Add proper tagger API for prompt metadata
if (id && version) {
const normalizedVariables = normalizePromptVariables(inputs.prompt.variables)
const chatTemplate = extractChatTemplateFromInstructions(response.instructions, normalizedVariables)
this._tagger._setTag(span, '_ml_obs.meta.input.prompt', {
id,
version,
variables: normalizedVariables,
chat_template: chatTemplate
})
const tags = { [PROMPT_TRACKING_INSTRUMENTATION_METHOD]: INSTRUMENTATION_METHOD_AUTO }
if (hasMultimodalInputs(inputs.prompt.variables)) {
tags[PROMPT_MULTIMODAL] = 'true'
}
this._tagger.tagSpanTags(span, tags)
}
}
const outputMetadata = {}
// Add fields from response object (convert numbers to floats)
if (response.temperature !== undefined) outputMetadata.temperature = Number(response.temperature)
if (response.top_p !== undefined) outputMetadata.top_p = Number(response.top_p)
if (response.tool_choice !== undefined) outputMetadata.tool_choice = response.tool_choice
if (response.truncation !== undefined) outputMetadata.truncation = response.truncation
if (response.text !== undefined) outputMetadata.text = response.text
this._tagger.tagMetadata(span, outputMetadata) // update the metadata with the output metadata
}
}
// TODO: this will be moved to the APM integration
function normalizeOpenAIResourceName (resource) {
switch (resource) {
// completions
case 'completions.create':
return 'createCompletion'
// chat completions
case 'chat.completions.create':
return 'createChatCompletion'
// embeddings
case 'embeddings.create':
return 'createEmbedding'
// responses
case 'responses.create':
return 'createResponse'
default:
return resource
}
}
function gateResource (resource) {
return ['createCompletion', 'createChatCompletion', 'createEmbedding', 'createResponse'].includes(resource)
? resource
: undefined
}
function getOperation (resource) {
switch (resource) {
case 'createCompletion':
return 'completion'
case 'createChatCompletion':
return 'chat'
case 'createEmbedding':
return 'embedding'
case 'createResponse':
return 'response'
default:
// should never happen
return 'unknown'
}
}
module.exports = OpenAiLLMObsPlugin