UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

724 lines (593 loc) 21.4 kB
'use strict' const path = require('path') const TracingPlugin = require('../../dd-trace/src/plugins/tracing') const { storage } = require('../../datadog-core') const services = require('./services') const Sampler = require('../../dd-trace/src/sampler') const { MEASURED } = require('../../../ext/tags') const { convertBuffersToObjects, constructCompletionResponseFromStreamedChunks, constructChatCompletionResponseFromStreamedChunks, constructResponseResponseFromStreamedChunks } = require('./stream-helpers') const { DD_MAJOR } = require('../../../version') class OpenAiTracingPlugin extends TracingPlugin { static id = 'openai' static operation = 'request' static system = 'openai' static prefix = 'tracing:apm:openai:request' constructor (...args) { super(...args) const { metrics, logger } = services.init(this._tracerConfig) this.metrics = metrics this.logger = logger this.sampler = new Sampler(0.1) // default 10% log sampling this.addSub('apm:openai:request:chunk', ({ ctx, chunk, done }) => { if (!ctx.chunks) ctx.chunks = [] if (chunk) ctx.chunks.push(chunk) if (!done) return let chunks = ctx.chunks if (chunks.length === 0) return const firstChunk = chunks[0] // OpenAI in legacy versions returns chunked buffers instead of objects. // These buffers will need to be combined and coalesced into a list of object chunks. if (firstChunk instanceof Buffer) { chunks = convertBuffersToObjects(chunks) } const methodName = ctx.currentStore.normalizedMethodName let n = 1 const prompt = ctx.args[0].prompt if (Array.isArray(prompt) && typeof prompt[0] !== 'number') { n *= prompt.length } let response = {} if (methodName === 'createCompletion') { response = constructCompletionResponseFromStreamedChunks(chunks, n) } else if (methodName === 'createChatCompletion') { response = constructChatCompletionResponseFromStreamedChunks(chunks, n) } else if (methodName === 'createResponse') { response = constructResponseResponseFromStreamedChunks(chunks) } ctx.result = { data: response } }) } configure (config) { if (config.enabled === false) { services.shutdown() } super.configure(config) } bindStart (ctx) { const { methodName, args } = ctx const payload = normalizeRequestPayload(methodName, args) const normalizedMethodName = normalizeMethodName(methodName) const store = storage('legacy').getStore() || {} // hold onto these to make response extraction matching efficient // the original method name corresponds to the SDK method name (e.g. createChatCompletion, chat.completions.create) // the normalized method name corresponds to the resource name (e.g. createChatCompletion, createCompletion) store.originalMethodName = methodName store.normalizedMethodName = normalizedMethodName const span = this.startSpan('openai.request', { service: this.config.service, resource: DD_MAJOR >= 6 ? normalizedMethodName : methodName, type: 'openai', kind: 'client', meta: { [MEASURED]: 1, // Only model is added to all requests 'openai.request.model': payload.model } }, false) const openaiStore = Object.create(null) const tags = {} // The remaining tags are added one at a time if (payload.stream) { tags['openai.request.stream'] = payload.stream } switch (normalizedMethodName) { case 'createImage': case 'createImageEdit': case 'createImageVariation': commonCreateImageRequestExtraction(tags, payload, openaiStore) break case 'createChatCompletion': createChatCompletionRequestExtraction(tags, payload, openaiStore) break case 'createFile': case 'retrieveFile': commonFileRequestExtraction(tags, payload) break case 'createTranscription': case 'createTranslation': commonCreateAudioRequestExtraction(tags, payload, openaiStore) break case 'retrieveModel': retrieveModelRequestExtraction(tags, payload) break case 'createEdit': createEditRequestExtraction(tags, payload, openaiStore) break case 'createResponse': createResponseRequestExtraction(tags, payload, openaiStore) break } span.addTags(tags) ctx.currentStore = { ...store, span, openai: openaiStore } return ctx.currentStore } asyncEnd (ctx) { const { result } = ctx const store = ctx.currentStore const span = store?.span if (!span) return const error = !!span.context()._tags.error let headers, body, method, path if (!error) { headers = result.headers body = result.data method = result.request.method path = result.request.path } if (!error && headers?.constructor.name === 'Headers') { headers = Object.fromEntries(headers) } const resource = span._spanContext._tags['resource.name'] const normalizedMethodName = store.normalizedMethodName body = coerceResponseBody(body, normalizedMethodName) const openaiStore = store.openai if (!error && (path?.startsWith('https://') || path?.startsWith('http://'))) { // basic checking for if the path was set as a full URL // not using a full regex as it will likely be "https://api.openai.com/..." path = new URL(path).pathname } const originalMethodName = store.originalMethodName const endpoint = lookupOperationEndpoint(normalizedMethodName, originalMethodName, path) const tags = error ? {} : { 'openai.request.endpoint': endpoint, 'openai.request.method': method.toUpperCase(), 'openai.response.model': headers['openai-model'] || body.model, // specific model, often undefined 'openai.response.id': body.id, // common creation value, numeric epoch 'openai.response.deleted': body.deleted, // common boolean field in delete responses // The OpenAI API appears to use both created and created_at in different places // Here we're conciously choosing to surface this inconsistency instead of normalizing 'openai.response.created': body.created, 'openai.response.created_at': body.created_at } responseDataExtractionByMethod(normalizedMethodName, tags, body, openaiStore) span.addTags(tags) span.finish() this.sendLog(resource, span, tags, openaiStore, error) this.sendMetrics(headers, body, endpoint, span._duration, error, tags) } sendMetrics (headers, body, endpoint, duration, error, spanTags) { const tags = [`error:${Number(!!error)}`] if (error) { this.metrics.increment('openai.request.error', 1, tags) } else { tags.push( `org:${headers['openai-organization']}`, `endpoint:${endpoint}`, `model:${headers['openai-model'] || body.model}` ) } this.metrics.distribution('openai.request.duration', duration * 1000, tags) const promptTokens = spanTags['openai.response.usage.prompt_tokens'] const promptTokensEstimated = spanTags['openai.response.usage.prompt_tokens_estimated'] const completionTokens = spanTags['openai.response.usage.completion_tokens'] const completionTokensEstimated = spanTags['openai.response.usage.completion_tokens_estimated'] const totalTokens = spanTags['openai.response.usage.total_tokens'] if (!error) { if (promptTokens != null) { if (promptTokensEstimated) { this.metrics.distribution( 'openai.tokens.prompt', promptTokens, [...tags, 'openai.estimated:true']) } else { this.metrics.distribution('openai.tokens.prompt', promptTokens, tags) } } if (completionTokens != null) { if (completionTokensEstimated) { this.metrics.distribution( 'openai.tokens.completion', completionTokens, [...tags, 'openai.estimated:true']) } else { this.metrics.distribution('openai.tokens.completion', completionTokens, tags) } } if (totalTokens != null) { if (promptTokensEstimated || completionTokensEstimated) { this.metrics.distribution( 'openai.tokens.total', totalTokens, [...tags, 'openai.estimated:true']) } else { this.metrics.distribution('openai.tokens.total', totalTokens, tags) } } } if (headers) { if (headers['x-ratelimit-limit-requests']) { this.metrics.gauge('openai.ratelimit.requests', Number(headers['x-ratelimit-limit-requests']), tags) } if (headers['x-ratelimit-remaining-requests']) { this.metrics.gauge( 'openai.ratelimit.remaining.requests', Number(headers['x-ratelimit-remaining-requests']), tags ) } if (headers['x-ratelimit-limit-tokens']) { this.metrics.gauge('openai.ratelimit.tokens', Number(headers['x-ratelimit-limit-tokens']), tags) } if (headers['x-ratelimit-remaining-tokens']) { this.metrics.gauge('openai.ratelimit.remaining.tokens', Number(headers['x-ratelimit-remaining-tokens']), tags) } } } sendLog (methodName, span, tags, openaiStore, error) { if (!openaiStore) return if (!Object.keys(openaiStore).length) return if (!this.sampler.isSampled(span)) return const log = { status: error ? 'error' : 'info', message: `sampled ${methodName}`, ...openaiStore } this.logger.log(log, span, tags) } } function normalizeMethodName (methodName) { switch (methodName) { // moderations case 'moderations.create': return 'createModeration' // completions case 'completions.create': return 'createCompletion' // chat completions case 'chat.completions.create': return 'createChatCompletion' // edits case 'edits.create': return 'createEdit' // embeddings case 'embeddings.create': return 'createEmbedding' // responses case 'responses.create': return 'createResponse' // files case 'files.create': return 'createFile' case 'files.retrieve': return 'retrieveFile' case 'files.del': case 'files.delete': return 'deleteFile' case 'files.retrieveContent': case 'files.content': return 'downloadFile' case 'files.list': return 'listFiles' // fine-tuning case 'fine_tuning.jobs.list': case 'fine-tune.list': return 'listFineTunes' case 'fine_tuning.jobs.listEvents': case 'fine-tune.listEvents': return 'listFineTuneEvents' case 'fine_tuning.jobs.create': case 'fine-tune.create': return 'createFineTune' case 'fine_tuning.jobs.retrieve': case 'fine-tune.retrieve': return 'retrieveFineTune' case 'fine_tuning.jobs.cancel': case 'fine-tune.cancel': return 'cancelFineTune' // audio case 'audio.transcriptions.create': return 'createTranscription' case 'audio.translations.create': return 'createTranslation' // images case 'images.generate': return 'createImage' case 'images.edit': return 'createImageEdit' case 'images.createVariation': return 'createImageVariation' // models case 'models.list': return 'listModels' case 'models.retrieve': return 'retrieveModel' case 'models.del': case 'models.delete': return 'deleteModel' default: return methodName } } function createEditRequestExtraction (tags, payload, openaiStore) { const instruction = payload.instruction openaiStore.instruction = instruction } function createResponseRequestExtraction (tags, payload, openaiStore) { // Extract model information if (payload.model) { tags['openai.request.model'] = payload.model } // Store the full payload for response extraction openaiStore.responseData = payload } function retrieveModelRequestExtraction (tags, payload) { tags['openai.request.id'] = payload.id } function createChatCompletionRequestExtraction (tags, payload, openaiStore) { const messages = payload.messages if (!defensiveArrayLength(messages)) return openaiStore.messages = payload.messages } function commonCreateImageRequestExtraction (tags, payload, openaiStore) { // createImageEdit, createImageVariation const img = payload.file || payload.image if (img !== null && typeof img === 'object' && img.path) { const file = path.basename(img.path) openaiStore.file = file } // createImageEdit if (payload.mask !== null && typeof payload.mask === 'object' && payload.mask.path) { const mask = path.basename(payload.mask.path) openaiStore.mask = mask } } function responseDataExtractionByMethod (methodName, tags, body, openaiStore) { switch (methodName) { case 'createCompletion': case 'createChatCompletion': case 'createEdit': commonCreateResponseExtraction(tags, body, openaiStore, methodName) break case 'createResponse': createResponseResponseExtraction(tags, body, openaiStore) break case 'listFiles': case 'listFineTunes': case 'listFineTuneEvents': commonListCountResponseExtraction(tags, body) break case 'createFile': case 'retrieveFile': createRetrieveFileResponseExtraction(tags, body) break case 'deleteFile': deleteFileResponseExtraction(tags, body) break case 'downloadFile': downloadFileResponseExtraction(tags, body) break case 'listModels': listModelsResponseExtraction(tags, body) break case 'retrieveModel': retrieveModelResponseExtraction(tags, body) break } } function retrieveModelResponseExtraction (tags, body) { tags['openai.response.owned_by'] = body.owned_by tags['openai.response.parent'] = body.parent tags['openai.response.root'] = body.root if (!body.permission) return tags['openai.response.permission.id'] = body.permission[0].id tags['openai.response.permission.created'] = body.permission[0].created tags['openai.response.permission.allow_create_engine'] = body.permission[0].allow_create_engine tags['openai.response.permission.allow_sampling'] = body.permission[0].allow_sampling tags['openai.response.permission.allow_logprobs'] = body.permission[0].allow_logprobs tags['openai.response.permission.allow_search_indices'] = body.permission[0].allow_search_indices tags['openai.response.permission.allow_view'] = body.permission[0].allow_view tags['openai.response.permission.allow_fine_tuning'] = body.permission[0].allow_fine_tuning tags['openai.response.permission.organization'] = body.permission[0].organization tags['openai.response.permission.group'] = body.permission[0].group tags['openai.response.permission.is_blocking'] = body.permission[0].is_blocking } function listModelsResponseExtraction (tags, body) { if (!body.data) return tags['openai.response.count'] = body.data.length } // the OpenAI package appears to stream the content download then provide it all as a singular string function downloadFileResponseExtraction (tags, body) { if (typeof body.file !== 'string') return tags['openai.response.total_bytes'] = Buffer.byteLength(body.file) } function deleteFileResponseExtraction (tags, body) { tags['openai.response.id'] = body.id } function commonCreateAudioRequestExtraction (tags, body, openaiStore) { if (body.file !== null && typeof body.file === 'object' && body.file.path) { const filename = path.basename(body.file.path) openaiStore.file = filename } } function commonFileRequestExtraction (tags, body) { tags['openai.request.purpose'] = body.purpose // User can provider either exact file contents or a file read stream // With the stream we extract the filepath // This is a best effort attempt to extract the filename during the request if (body.file !== null && typeof body.file === 'object' && body.file.path) { tags['openai.request.filename'] = path.basename(body.file.path) } } function createRetrieveFileResponseExtraction (tags, body) { tags['openai.response.filename'] = body.filename tags['openai.response.purpose'] = body.purpose tags['openai.response.bytes'] = body.bytes tags['openai.response.status'] = body.status tags['openai.response.status_details'] = body.status_details } function commonListCountResponseExtraction (tags, body) { if (!body.data) return tags['openai.response.count'] = body.data.length } // createCompletion, createChatCompletion, createEdit function commonCreateResponseExtraction (tags, body, openaiStore, methodName) { if (!body.choices) return openaiStore.choices = body.choices } function createResponseResponseExtraction (tags, body, openaiStore) { // Extract response ID if available if (body.id) { tags['openai.response.id'] = body.id } // Extract status if available if (body.status) { tags['openai.response.status'] = body.status } // Extract model from response if available if (body.model) { tags['openai.response.model'] = body.model } // Store the full response for potential future use openaiStore.response = body } // The server almost always responds with JSON function coerceResponseBody (body, methodName) { switch (methodName) { case 'downloadFile': return { file: body } } const type = typeof body if (type === 'string') { try { return JSON.parse(body) } catch { return body } } else if (type === 'object') { return body } else { return {} } } // This method is used to replace a dynamic URL segment with an asterisk function lookupOperationEndpoint (operationId, methodName, url) { switch (operationId) { case 'deleteModel': case 'retrieveModel': return '/v1/models/*' case 'deleteFile': case 'retrieveFile': return '/v1/files/*' case 'downloadFile': return '/v1/files/*/content' case 'retrieveFineTune': switch (methodName) { case 'fine_tuning.jobs.retrieve': return '/v1/fine_tuning/jobs/*' default: return '/v1/fine-tunes/*' } case 'listFineTuneEvents': switch (methodName) { case 'fine_tuning.jobs.listEvents': return '/v1/fine_tuning/jobs/*/events' default: return '/v1/fine-tunes/*/events' } case 'cancelFineTune': switch (methodName) { case 'fine_tuning.jobs.cancel': return '/v1/fine_tuning/jobs/*/cancel' default: return '/v1/fine-tunes/*/cancel' } } return url } /** * This function essentially normalizes the OpenAI method interface. Many methods accept * a single object argument. The remaining ones take individual arguments. This function * turns the individual arguments into an object to make extracting properties consistent. */ function normalizeRequestPayload (methodName, args) { switch (methodName) { case 'listModels': case 'models.list': case 'listFiles': case 'files.list': case 'listFineTunes': case 'fine_tuning.jobs.list': case 'fine-tune.list': // no argument return {} case 'retrieveModel': case 'models.retrieve': return { id: args[0] } case 'createFile': return { file: args[0], purpose: args[1] } case 'deleteFile': case 'files.del': case 'files.delete': case 'retrieveFile': case 'files.retrieve': case 'downloadFile': case 'files.retrieveContent': case 'files.content': return { file_id: args[0] } case 'listFineTuneEvents': case 'fine_tuning.jobs.listEvents': case 'fine-tune.listEvents': return { fine_tune_id: args[0], stream: args[1] // undocumented } case 'retrieveFineTune': case 'fine_tuning.jobs.retrieve': case 'fine-tune.retrieve': case 'deleteModel': case 'models.del': case 'models.delete': case 'cancelFineTune': case 'fine_tuning.jobs.cancel': case 'fine-tune.cancel': return { fine_tune_id: args[0] } case 'createImageEdit': return { file: args[0], prompt: args[1], // Note: order of prompt/mask in Node.js lib differs from public docs mask: args[2], n: args[3], size: args[4], response_format: args[5], user: args[6] } case 'createImageVariation': return { file: args[0], n: args[1], size: args[2], response_format: args[3], user: args[4] } case 'createTranscription': case 'createTranslation': return { file: args[0], model: args[1], prompt: args[2], response_format: args[3], temperature: args[4], language: args[5] // only used for createTranscription } } // Remaining OpenAI methods take a single object argument return args[0] } function defensiveArrayLength (maybeArray) { if (maybeArray) { // Detect singular item (ie body.training_file vs body.training_files) return Array.isArray(maybeArray) ? maybeArray.length : 1 } } module.exports = OpenAiTracingPlugin