diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/openai/scenario.mjs rename to dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs new file mode 100644 index 000000000000..9cdb24a42da9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs @@ -0,0 +1,67 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.embeddings = { + create: async params => { + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + return { + object: 'list', + data: [ + { + object: 'embedding', + embedding: [0.1, 0.2, 0.3], + index: 0, + }, + ], + model: params.model, + usage: { + prompt_tokens: 10, + total_tokens: 10, + }, + }; + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // First test: embeddings API + await client.embeddings.create({ + input: 'Embedding test!', + model: 'text-embedding-3-small', + dimensions: 1536, + encoding_format: 'float', + }); + + // Second test: embeddings API error model + try { + await client.embeddings.create({ + input: 'Error embedding test!', + model: 'error-model', + }); + } catch { + // Error is expected and handled + } + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 5cbb27df73bf..116c3a6208fa 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -6,7 +6,7 @@ describe('OpenAI integration', () => { cleanupChildProcesses(); }); - const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_CHAT = { transaction: 'main', spans: expect.arrayContaining([ // First span - basic chat completion without PII @@ -147,7 +147,7 @@ describe('OpenAI integration', () => { ]), }; - const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_CHAT = { transaction: 'main', spans: expect.arrayContaining([ // First span - basic chat completion with PII @@ -321,27 +321,27 @@ describe('OpenAI integration', () => { ]), }; - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument.mjs', (createRunner, test) => { test('creates openai related spans with sendDefaultPii: false', async () => { await createRunner() .ignore('event') - .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_CHAT }) .start() .completed(); }); }); - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates openai related spans with sendDefaultPii: true', async () => { await createRunner() .ignore('event') - .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_CHAT }) .start() .completed(); }); }); - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates openai related spans with custom options', async () => { await createRunner() .ignore('event') @@ -351,6 +351,109 @@ describe('OpenAI integration', () => { }); }); + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embeddings API + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'text-embedding-3-small', + 'gen_ai.request.encoding_format': 'float', + 'gen_ai.request.dimensions': 1536, + 'gen_ai.response.model': 'text-embedding-3-small', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.total_tokens': 10, + 'openai.response.model': 'text-embedding-3-small', + 'openai.usage.prompt_tokens': 10, + }, + description: 'embeddings text-embedding-3-small', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - embeddings API error model + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embeddings API with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'text-embedding-3-small', + 'gen_ai.request.encoding_format': 'float', + 'gen_ai.request.dimensions': 1536, + 'gen_ai.request.messages': 'Embedding test!', + 'gen_ai.response.model': 'text-embedding-3-small', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.total_tokens': 10, + 'openai.response.model': 'text-embedding-3-small', + 'openai.usage.prompt_tokens': 10, + }, + description: 'embeddings text-embedding-3-small', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - embeddings API error model with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': 'Error embedding test!', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS }) + .start() + .completed(); + }); + }); + createEsmAndCjsTests(__dirname, 'scenario-root-span.mjs', 'instrument.mjs', (createRunner, test) => { test('it works without a wrapping span', async () => { await createRunner() @@ -400,7 +503,7 @@ describe('OpenAI integration', () => { createEsmAndCjsTests( __dirname, - 'scenario-message-truncation-completions.mjs', + 'truncation/scenario-message-truncation-completions.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { @@ -436,7 +539,7 @@ describe('OpenAI integration', () => { createEsmAndCjsTests( __dirname, - 'scenario-message-truncation-responses.mjs', + 'truncation/scenario-message-truncation-responses.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('truncates string inputs when they exceed byte limit', async () => { @@ -469,4 +572,30 @@ describe('OpenAI integration', () => { }); }, ); + + createEsmAndCjsTests( + __dirname, + 'truncation/scenario-message-truncation-embeddings.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'embeddings', + }), + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation-completions.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-completions.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation-completions.mjs rename to dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-completions.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-embeddings.mjs new file mode 100644 index 000000000000..b2e5cf3206fe --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-embeddings.mjs @@ -0,0 +1,66 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.embeddings = { + create: async params => { + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + object: 'list', + data: [ + { + object: 'embedding', + embedding: [0.1, 0.2, 0.3], + index: 0, + }, + ], + model: params.model, + usage: { + prompt_tokens: 10, + total_tokens: 10, + }, + }; + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // Create 1 large input that gets truncated to fit within the 20KB limit + const largeContent = 'A'.repeat(25000) + 'B'.repeat(25000); // ~50KB gets truncated to include only As + + await client.embeddings.create({ + input: largeContent, + model: 'text-embedding-3-small', + dimensions: 1536, + encoding_format: 'float', + }); + + // Create 3 large inputs where: + // - First 2 inputs are very large (will be dropped) + // - Last input is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.embeddings.create({ + input: [largeContent1, largeContent2, largeContent3], + model: 'text-embedding-3-small', + dimensions: 1536, + encoding_format: 'float', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation-responses.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-responses.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation-responses.mjs rename to dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-responses.mjs diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index b07aa63d306f..e2808d5f2642 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -65,6 +65,16 @@ export const GEN_AI_REQUEST_TOP_K_ATTRIBUTE = 'gen_ai.request.top_k'; */ export const GEN_AI_REQUEST_STOP_SEQUENCES_ATTRIBUTE = 'gen_ai.request.stop_sequences'; +/** + * The encoding format for the model request + */ +export const GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE = 'gen_ai.request.encoding_format'; + +/** + * The dimensions for the model request + */ +export const GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE = 'gen_ai.request.dimensions'; + /** * Array of reasons why the model stopped generating tokens */ @@ -208,6 +218,7 @@ export const OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE = 'openai.usage.prompt_tokens' export const OPENAI_OPERATIONS = { CHAT: 'chat', RESPONSES: 'responses', + EMBEDDINGS: 'embeddings', } as const; // ============================================================================= diff --git a/packages/core/src/tracing/openai/constants.ts b/packages/core/src/tracing/openai/constants.ts index c4952b123b0f..e8b5c6ddc87f 100644 --- a/packages/core/src/tracing/openai/constants.ts +++ b/packages/core/src/tracing/openai/constants.ts @@ -2,7 +2,7 @@ export const OPENAI_INTEGRATION_NAME = 'OpenAI'; // https://platform.openai.com/docs/quickstart?api-mode=responses // https://platform.openai.com/docs/quickstart?api-mode=chat -export const INSTRUMENTED_METHODS = ['responses.create', 'chat.completions.create'] as const; +export const INSTRUMENTED_METHODS = ['responses.create', 'chat.completions.create', 'embeddings.create'] as const; export const RESPONSES_TOOL_CALL_EVENT_TYPES = [ 'response.output_item.added', 'response.function_call_arguments.delta', diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index bb099199772c..bba2ee0f5afd 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -7,6 +7,8 @@ import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, + GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE, + GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, @@ -14,9 +16,7 @@ import { GEN_AI_REQUEST_STREAM_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, GEN_AI_REQUEST_TOP_P_ATTRIBUTE, - GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, - GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { getTruncatedJsonString } from '../ai/utils'; @@ -25,22 +25,22 @@ import { instrumentStream } from './streaming'; import type { ChatCompletionChunk, InstrumentedMethod, - OpenAiChatCompletionObject, OpenAiIntegration, OpenAiOptions, OpenAiResponse, - OpenAIResponseObject, OpenAIStream, ResponseStreamingEvent, } from './types'; import { + addChatCompletionAttributes, + addEmbeddingsAttributes, + addResponsesApiAttributes, buildMethodPath, getOperationName, getSpanOperation, isChatCompletionResponse, + isEmbeddingsResponse, isResponsesApiResponse, - setCommonResponseAttributes, - setTokenUsageAttributes, shouldInstrument, } from './utils'; @@ -82,6 +82,8 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record choice.finish_reason) - .filter((reason): reason is string => reason !== null); - if (finishReasons.length > 0) { - span.setAttributes({ - [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify(finishReasons), - }); - } - - // Extract tool calls from all choices (only if recordOutputs is true) - if (recordOutputs) { - const toolCalls = response.choices - .map(choice => choice.message?.tool_calls) - .filter(calls => Array.isArray(calls) && calls.length > 0) - .flat(); - - if (toolCalls.length > 0) { - span.setAttributes({ - [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(toolCalls), - }); - } - } - } -} - -/** - * Add attributes for Responses API responses - */ -function addResponsesApiAttributes(span: Span, response: OpenAIResponseObject, recordOutputs?: boolean): void { - setCommonResponseAttributes(span, response.id, response.model, response.created_at); - if (response.status) { - span.setAttributes({ - [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify([response.status]), - }); - } - if (response.usage) { - setTokenUsageAttributes( - span, - response.usage.input_tokens, - response.usage.output_tokens, - response.usage.total_tokens, - ); - } - - // Extract function calls from output (only if recordOutputs is true) - if (recordOutputs) { - const responseWithOutput = response as OpenAIResponseObject & { output?: unknown[] }; - if (Array.isArray(responseWithOutput.output) && responseWithOutput.output.length > 0) { - // Filter for function_call type objects in the output array - const functionCalls = responseWithOutput.output.filter( - (item): unknown => - typeof item === 'object' && item !== null && (item as Record).type === 'function_call', - ); - - if (functionCalls.length > 0) { - span.setAttributes({ - [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(functionCalls), - }); - } - } - } -} - /** * Add response attributes to spans * This currently supports both Chat Completion and Responses API responses @@ -186,6 +111,8 @@ function addResponseAttributes(span: Span, result: unknown, recordOutputs?: bool if (recordOutputs && response.output_text) { span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.output_text }); } + } else if (isEmbeddingsResponse(response)) { + addEmbeddingsAttributes(span, response); } } diff --git a/packages/core/src/tracing/openai/types.ts b/packages/core/src/tracing/openai/types.ts index daa478db4ba6..6dcd644bfe17 100644 --- a/packages/core/src/tracing/openai/types.ts +++ b/packages/core/src/tracing/openai/types.ts @@ -131,7 +131,29 @@ export interface OpenAIResponseObject { metadata: Record; } -export type OpenAiResponse = OpenAiChatCompletionObject | OpenAIResponseObject; +/** + * @see https://platform.openai.com/docs/api-reference/embeddings/object + */ +export interface OpenAIEmbeddingsObject { + object: 'embedding'; + embedding: number[]; + index: number; +} + +/** + * @see https://platform.openai.com/docs/api-reference/embeddings/create + */ +export interface OpenAICreateEmbeddingsObject { + object: 'list'; + data: OpenAIEmbeddingsObject[]; + model: string; + usage: { + prompt_tokens: number; + total_tokens: number; + }; +} + +export type OpenAiResponse = OpenAiChatCompletionObject | OpenAIResponseObject | OpenAICreateEmbeddingsObject; /** * Streaming event types for the Responses API diff --git a/packages/core/src/tracing/openai/utils.ts b/packages/core/src/tracing/openai/utils.ts index 17007693e739..4dff5b4fdbb8 100644 --- a/packages/core/src/tracing/openai/utils.ts +++ b/packages/core/src/tracing/openai/utils.ts @@ -1,7 +1,9 @@ import type { Span } from '../../types-hoist/span'; import { + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_ID_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, @@ -17,6 +19,7 @@ import type { ChatCompletionChunk, InstrumentedMethod, OpenAiChatCompletionObject, + OpenAICreateEmbeddingsObject, OpenAIResponseObject, ResponseStreamingEvent, } from './types'; @@ -31,6 +34,9 @@ export function getOperationName(methodPath: string): string { if (methodPath.includes('responses')) { return OPENAI_OPERATIONS.RESPONSES; } + if (methodPath.includes('embeddings')) { + return OPENAI_OPERATIONS.EMBEDDINGS; + } return methodPath.split('.').pop() || 'unknown'; } @@ -80,6 +86,21 @@ export function isResponsesApiResponse(response: unknown): response is OpenAIRes ); } +/** + * Check if response is an Embeddings API object + */ +export function isEmbeddingsResponse(response: unknown): response is OpenAICreateEmbeddingsObject { + if (response === null || typeof response !== 'object' || !('object' in response)) { + return false; + } + const responseObject = response as Record; + return ( + responseObject.object === 'list' && + typeof responseObject.model === 'string' && + responseObject.model.toLowerCase().includes('embedding') + ); +} + /** * Check if streaming event is from the Responses API */ @@ -105,6 +126,101 @@ export function isChatCompletionChunk(event: unknown): event is ChatCompletionCh ); } +/** + * Add attributes for Chat Completion responses + */ +export function addChatCompletionAttributes( + span: Span, + response: OpenAiChatCompletionObject, + recordOutputs?: boolean, +): void { + setCommonResponseAttributes(span, response.id, response.model, response.created); + if (response.usage) { + setTokenUsageAttributes( + span, + response.usage.prompt_tokens, + response.usage.completion_tokens, + response.usage.total_tokens, + ); + } + if (Array.isArray(response.choices)) { + const finishReasons = response.choices + .map(choice => choice.finish_reason) + .filter((reason): reason is string => reason !== null); + if (finishReasons.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify(finishReasons), + }); + } + + // Extract tool calls from all choices (only if recordOutputs is true) + if (recordOutputs) { + const toolCalls = response.choices + .map(choice => choice.message?.tool_calls) + .filter(calls => Array.isArray(calls) && calls.length > 0) + .flat(); + + if (toolCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(toolCalls), + }); + } + } + } +} + +/** + * Add attributes for Responses API responses + */ +export function addResponsesApiAttributes(span: Span, response: OpenAIResponseObject, recordOutputs?: boolean): void { + setCommonResponseAttributes(span, response.id, response.model, response.created_at); + if (response.status) { + span.setAttributes({ + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify([response.status]), + }); + } + if (response.usage) { + setTokenUsageAttributes( + span, + response.usage.input_tokens, + response.usage.output_tokens, + response.usage.total_tokens, + ); + } + + // Extract function calls from output (only if recordOutputs is true) + if (recordOutputs) { + const responseWithOutput = response as OpenAIResponseObject & { output?: unknown[] }; + if (Array.isArray(responseWithOutput.output) && responseWithOutput.output.length > 0) { + // Filter for function_call type objects in the output array + const functionCalls = responseWithOutput.output.filter( + (item): unknown => + typeof item === 'object' && item !== null && (item as Record).type === 'function_call', + ); + + if (functionCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(functionCalls), + }); + } + } + } +} + +/** + * Add attributes for Embeddings API responses + */ +export function addEmbeddingsAttributes(span: Span, response: OpenAICreateEmbeddingsObject): void { + span.setAttributes({ + [OPENAI_RESPONSE_MODEL_ATTRIBUTE]: response.model, + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: response.model, + }); + + if (response.usage) { + setTokenUsageAttributes(span, response.usage.prompt_tokens, undefined, response.usage.total_tokens); + } +} + /** * Set token usage attributes * @param span - The span to add attributes to