diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-thread-id.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-thread-id.mjs new file mode 100644 index 000000000000..415d85215278 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-thread-id.mjs @@ -0,0 +1,67 @@ +import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; +import * as Sentry from '@sentry/node'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'langgraph-thread-id-test' }, async () => { + // Define a simple mock LLM function + const mockLlm = () => { + return { + messages: [ + { + role: 'assistant', + content: 'Mock LLM response', + response_metadata: { + model_name: 'mock-model', + finish_reason: 'stop', + tokenUsage: { + promptTokens: 20, + completionTokens: 10, + totalTokens: 30, + }, + }, + }, + ], + }; + }; + + // Create and compile the graph + const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', mockLlm) + .addEdge(START, 'agent') + .addEdge('agent', END) + .compile({ name: 'thread_test_agent' }); + + // Test 1: Invoke with thread_id in config + await graph.invoke( + { + messages: [{ role: 'user', content: 'Hello with thread ID' }], + }, + { + configurable: { + thread_id: 'thread_abc123_session_1', + }, + }, + ); + + // Test 2: Invoke with different thread_id (simulating different conversation) + await graph.invoke( + { + messages: [{ role: 'user', content: 'Different conversation' }], + }, + { + configurable: { + thread_id: 'thread_xyz789_session_2', + }, + }, + ); + + // Test 3: Invoke without thread_id (should not have gen_ai.conversation.id) + await graph.invoke({ + messages: [{ role: 'user', content: 'No thread ID here' }], + }); + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 6a67b5cd1e86..bafcdf49a32c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -205,4 +205,72 @@ describe('LangGraph integration', () => { await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_WITH_TOOLS }).start().completed(); }); }); + + // Test for thread_id (conversation ID) support + const EXPECTED_TRANSACTION_THREAD_ID = { + transaction: 'langgraph-thread-id-test', + spans: expect.arrayContaining([ + // create_agent span + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'create_agent', + 'sentry.op': 'gen_ai.create_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'thread_test_agent', + }, + description: 'create_agent thread_test_agent', + op: 'gen_ai.create_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // First invoke_agent span with thread_id + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'thread_test_agent', + 'gen_ai.pipeline.name': 'thread_test_agent', + // The thread_id should be captured as conversation.id + 'gen_ai.conversation.id': 'thread_abc123_session_1', + }), + description: 'invoke_agent thread_test_agent', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // Second invoke_agent span with different thread_id + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'thread_test_agent', + 'gen_ai.pipeline.name': 'thread_test_agent', + // Different thread_id for different conversation + 'gen_ai.conversation.id': 'thread_xyz789_session_2', + }), + description: 'invoke_agent thread_test_agent', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // Third invoke_agent span without thread_id (should NOT have gen_ai.conversation.id) + expect.objectContaining({ + data: expect.not.objectContaining({ + 'gen_ai.conversation.id': expect.anything(), + }), + description: 'invoke_agent thread_test_agent', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-thread-id.mjs', 'instrument.mjs', (createRunner, test) => { + test('should capture thread_id as gen_ai.conversation.id', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_THREAD_ID }).start().completed(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-conversation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-conversation.mjs new file mode 100644 index 000000000000..7088a6ca9cbe --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-conversation.mjs @@ -0,0 +1,95 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + // Conversations API endpoint - create conversation + app.post('/openai/conversations', (req, res) => { + res.send({ + id: 'conv_689667905b048191b4740501625afd940c7533ace33a2dab', + object: 'conversation', + created_at: 1704067200, + metadata: {}, + }); + }); + + // Responses API endpoint - with conversation support + app.post('/openai/responses', (req, res) => { + const { model, conversation, previous_response_id } = req.body; + + res.send({ + id: 'resp_mock_conv_123', + object: 'response', + created_at: 1704067210, + model: model, + output: [ + { + type: 'message', + id: 'msg_mock_output_1', + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + text: `Response with conversation: ${conversation || 'none'}, previous_response_id: ${previous_response_id || 'none'}`, + annotations: [], + }, + ], + }, + ], + output_text: `Response with conversation: ${conversation || 'none'}`, + status: 'completed', + usage: { + input_tokens: 10, + output_tokens: 15, + total_tokens: 25, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'conversation-test' }, async () => { + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + // Test 1: Create a conversation + const conversation = await client.conversations.create(); + + // Test 2: Use conversation ID in responses.create + await client.responses.create({ + model: 'gpt-4', + input: 'Hello, this is a conversation test', + conversation: conversation.id, + }); + + // Test 3: Use previous_response_id for chaining (without formal conversation) + const firstResponse = await client.responses.create({ + model: 'gpt-4', + input: 'Tell me a joke', + }); + + await client.responses.create({ + model: 'gpt-4', + input: 'Explain why that is funny', + previous_response_id: firstResponse.id, + }); + }); + + server.close(); +} + +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 d56bb27f6a24..db3a592a4870 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -645,4 +645,75 @@ describe('OpenAI integration', () => { }); }, ); + + // Test for conversation ID support (Conversations API and previous_response_id) + const EXPECTED_TRANSACTION_CONVERSATION = { + transaction: 'conversation-test', + spans: expect.arrayContaining([ + // First span - conversations.create returns conversation object with id + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'conversations', + 'sentry.op': 'gen_ai.conversations', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + // The conversation ID should be captured from the response + 'gen_ai.conversation.id': 'conv_689667905b048191b4740501625afd940c7533ace33a2dab', + }), + description: 'conversations unknown', + op: 'gen_ai.conversations', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - responses.create with conversation parameter + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + // The conversation ID should be captured from the request + 'gen_ai.conversation.id': 'conv_689667905b048191b4740501625afd940c7533ace33a2dab', + }), + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Third span - responses.create without conversation (first in chain, should NOT have gen_ai.conversation.id) + expect.objectContaining({ + data: expect.not.objectContaining({ + 'gen_ai.conversation.id': expect.anything(), + }), + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Fourth span - responses.create with previous_response_id (chaining) + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + // The previous_response_id should be captured as conversation.id + 'gen_ai.conversation.id': 'resp_mock_conv_123', + }), + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-conversation.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures conversation ID from Conversations API and previous_response_id', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_CONVERSATION }) + .start() + .completed(); + }); + }); }); diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index e76b2945b497..154e90cbaec1 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -154,6 +154,13 @@ export const GEN_AI_AGENT_NAME_ATTRIBUTE = 'gen_ai.agent.name'; */ export const GEN_AI_PIPELINE_NAME_ATTRIBUTE = 'gen_ai.pipeline.name'; +/** + * The conversation ID for linking messages across API calls + * For OpenAI Assistants API: thread_id + * For LangGraph: configurable.thread_id + */ +export const GEN_AI_CONVERSATION_ID_ATTRIBUTE = 'gen_ai.conversation.id'; + /** * The number of cache creation input tokens used */ @@ -254,6 +261,7 @@ export const OPENAI_OPERATIONS = { CHAT: 'chat', RESPONSES: 'responses', EMBEDDINGS: 'embeddings', + CONVERSATIONS: 'conversations', } as const; // ============================================================================= diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index 5601cddf458b..cfbe18bc4f88 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -3,6 +3,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from ' import { SPAN_STATUS_ERROR } from '../../tracing'; import { GEN_AI_AGENT_NAME_ATTRIBUTE, + GEN_AI_CONVERSATION_ID_ATTRIBUTE, GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_PIPELINE_NAME_ATTRIBUTE, @@ -113,6 +114,15 @@ function instrumentCompiledGraphInvoke( span.updateName(`invoke_agent ${graphName}`); } + // Extract thread_id from the config (second argument) + // LangGraph uses config.configurable.thread_id for conversation/session linking + const config = args.length > 1 ? (args[1] as Record | undefined) : undefined; + const configurable = config?.configurable as Record | undefined; + const threadId = configurable?.thread_id; + if (threadId && typeof threadId === 'string') { + span.setAttribute(GEN_AI_CONVERSATION_ID_ATTRIBUTE, threadId); + } + // Extract available tools from the graph instance const tools = extractToolsFromCompiledGraph(graphInstance); if (tools) { diff --git a/packages/core/src/tracing/openai/constants.ts b/packages/core/src/tracing/openai/constants.ts index e8b5c6ddc87f..426cda443680 100644 --- a/packages/core/src/tracing/openai/constants.ts +++ b/packages/core/src/tracing/openai/constants.ts @@ -2,7 +2,15 @@ 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', 'embeddings.create'] as const; +// https://platform.openai.com/docs/api-reference/conversations +export const INSTRUMENTED_METHODS = [ + 'responses.create', + 'chat.completions.create', + 'embeddings.create', + // Conversations API - for conversation state management + // https://platform.openai.com/docs/guides/conversation-state + 'conversations.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 c68e920daf2b..031cbb8ee47e 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -7,15 +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, - GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, - GEN_AI_REQUEST_STREAM_ATTRIBUTE, - GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, - GEN_AI_REQUEST_TOP_P_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; @@ -31,17 +24,34 @@ import type { } from './types'; import { addChatCompletionAttributes, + addConversationAttributes, addEmbeddingsAttributes, addResponsesApiAttributes, buildMethodPath, + extractRequestParameters, getOperationName, getSpanOperation, isChatCompletionResponse, + isConversationResponse, isEmbeddingsResponse, isResponsesApiResponse, shouldInstrument, } from './utils'; +/** + * Extract available tools from request parameters + */ +function extractAvailableTools(params: Record): string | undefined { + const tools = Array.isArray(params.tools) ? params.tools : []; + const hasWebSearchOptions = params.web_search_options && typeof params.web_search_options === 'object'; + const webSearchOptions = hasWebSearchOptions + ? [{ type: 'web_search_options', ...(params.web_search_options as Record) }] + : []; + + const availableTools = [...tools, ...webSearchOptions]; + return availableTools.length > 0 ? JSON.stringify(availableTools) : undefined; +} + /** * Extract request attributes from method arguments */ @@ -52,36 +62,15 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record 0 && typeof args[0] === 'object' && args[0] !== null) { const params = args[0] as Record; - const tools = Array.isArray(params.tools) ? params.tools : []; - const hasWebSearchOptions = params.web_search_options && typeof params.web_search_options === 'object'; - const webSearchOptions = hasWebSearchOptions - ? [{ type: 'web_search_options', ...(params.web_search_options as Record) }] - : []; - - const availableTools = [...tools, ...webSearchOptions]; - - if (availableTools.length > 0) { - attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = JSON.stringify(availableTools); + const availableTools = extractAvailableTools(params); + if (availableTools) { + attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = availableTools; } - } - - if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { - const params = args[0] as Record; - attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = params.model ?? 'unknown'; - if ('temperature' in params) attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE] = params.temperature; - if ('top_p' in params) attributes[GEN_AI_REQUEST_TOP_P_ATTRIBUTE] = params.top_p; - if ('frequency_penalty' in params) - attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE] = params.frequency_penalty; - if ('presence_penalty' in params) attributes[GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE] = params.presence_penalty; - if ('stream' in params) attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = params.stream; - if ('encoding_format' in params) attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE] = params.encoding_format; - if ('dimensions' in params) attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] = params.dimensions; + Object.assign(attributes, extractRequestParameters(params)); } else { attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = 'unknown'; } @@ -91,7 +80,7 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record; +} + +export type OpenAiResponse = + | OpenAiChatCompletionObject + | OpenAIResponseObject + | OpenAICreateEmbeddingsObject + | OpenAIConversationObject; /** * 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 4dff5b4fdbb8..007dd93a91b1 100644 --- a/packages/core/src/tracing/openai/utils.ts +++ b/packages/core/src/tracing/openai/utils.ts @@ -1,5 +1,14 @@ import type { Span } from '../../types-hoist/span'; import { + GEN_AI_CONVERSATION_ID_ATTRIBUTE, + GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE, + GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE, + GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, + 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_ID_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, @@ -19,6 +28,7 @@ import type { ChatCompletionChunk, InstrumentedMethod, OpenAiChatCompletionObject, + OpenAIConversationObject, OpenAICreateEmbeddingsObject, OpenAIResponseObject, ResponseStreamingEvent, @@ -37,6 +47,9 @@ export function getOperationName(methodPath: string): string { if (methodPath.includes('embeddings')) { return OPENAI_OPERATIONS.EMBEDDINGS; } + if (methodPath.includes('conversations')) { + return OPENAI_OPERATIONS.CONVERSATIONS; + } return methodPath.split('.').pop() || 'unknown'; } @@ -101,6 +114,19 @@ export function isEmbeddingsResponse(response: unknown): response is OpenAICreat ); } +/** + * Check if response is a Conversations API object + * @see https://platform.openai.com/docs/api-reference/conversations + */ +export function isConversationResponse(response: unknown): response is OpenAIConversationObject { + return ( + response !== null && + typeof response === 'object' && + 'object' in response && + (response as Record).object === 'conversation' + ); +} + /** * Check if streaming event is from the Responses API */ @@ -221,6 +247,27 @@ export function addEmbeddingsAttributes(span: Span, response: OpenAICreateEmbedd } } +/** + * Add attributes for Conversations API responses + * @see https://platform.openai.com/docs/api-reference/conversations + */ +export function addConversationAttributes(span: Span, response: OpenAIConversationObject): void { + const { id, created_at } = response; + + span.setAttributes({ + [OPENAI_RESPONSE_ID_ATTRIBUTE]: id, + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: id, + // The conversation id is used to link messages across API calls + [GEN_AI_CONVERSATION_ID_ATTRIBUTE]: id, + }); + + if (created_at) { + span.setAttributes({ + [OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(created_at * 1000).toISOString(), + }); + } +} + /** * Set token usage attributes * @param span - The span to add attributes to @@ -273,3 +320,45 @@ export function setCommonResponseAttributes(span: Span, id: string, model: strin [OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(timestamp * 1000).toISOString(), }); } + +/** + * Extract conversation ID from request parameters + * Supports both Conversations API and previous_response_id chaining + * @see https://platform.openai.com/docs/guides/conversation-state + */ +function extractConversationId(params: Record): string | undefined { + // Conversations API: conversation parameter (e.g., "conv_...") + if ('conversation' in params && typeof params.conversation === 'string') { + return params.conversation; + } + // Responses chaining: previous_response_id links to parent response + if ('previous_response_id' in params && typeof params.previous_response_id === 'string') { + return params.previous_response_id; + } + return undefined; +} + +/** + * Extract request parameters including model settings and conversation context + */ +export function extractRequestParameters(params: Record): Record { + const attributes: Record = { + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: params.model ?? 'unknown', + }; + + if ('temperature' in params) attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE] = params.temperature; + if ('top_p' in params) attributes[GEN_AI_REQUEST_TOP_P_ATTRIBUTE] = params.top_p; + if ('frequency_penalty' in params) attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE] = params.frequency_penalty; + if ('presence_penalty' in params) attributes[GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE] = params.presence_penalty; + if ('stream' in params) attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = params.stream; + if ('encoding_format' in params) attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE] = params.encoding_format; + if ('dimensions' in params) attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] = params.dimensions; + + // Capture conversation ID for linking messages across API calls + const conversationId = extractConversationId(params); + if (conversationId) { + attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE] = conversationId; + } + + return attributes; +} diff --git a/packages/core/test/lib/utils/openai-utils.test.ts b/packages/core/test/lib/utils/openai-utils.test.ts index c68a35e5becc..ff951e8be40b 100644 --- a/packages/core/test/lib/utils/openai-utils.test.ts +++ b/packages/core/test/lib/utils/openai-utils.test.ts @@ -5,6 +5,7 @@ import { getSpanOperation, isChatCompletionChunk, isChatCompletionResponse, + isConversationResponse, isResponsesApiResponse, isResponsesApiStreamEvent, shouldInstrument, @@ -22,6 +23,11 @@ describe('openai-utils', () => { expect(getOperationName('some.path.responses.method')).toBe('responses'); }); + it('should return conversations for conversations methods', () => { + expect(getOperationName('conversations.create')).toBe('conversations'); + expect(getOperationName('some.path.conversations.method')).toBe('conversations'); + }); + it('should return the last part of path for unknown methods', () => { expect(getOperationName('some.unknown.method')).toBe('method'); expect(getOperationName('create')).toBe('create'); @@ -44,6 +50,7 @@ describe('openai-utils', () => { it('should return true for instrumented methods', () => { expect(shouldInstrument('responses.create')).toBe(true); expect(shouldInstrument('chat.completions.create')).toBe(true); + expect(shouldInstrument('conversations.create')).toBe(true); }); it('should return false for non-instrumented methods', () => { @@ -146,4 +153,36 @@ describe('openai-utils', () => { expect(isChatCompletionChunk({ object: null })).toBe(false); }); }); + + describe('isConversationResponse', () => { + it('should return true for valid conversation responses', () => { + const validConversation = { + object: 'conversation', + id: 'conv_689667905b048191b4740501625afd940c7533ace33a2dab', + created_at: 1704067200, + }; + expect(isConversationResponse(validConversation)).toBe(true); + }); + + it('should return true for conversation with metadata', () => { + const conversationWithMetadata = { + object: 'conversation', + id: 'conv_123', + created_at: 1704067200, + metadata: { user_id: 'user_123' }, + }; + expect(isConversationResponse(conversationWithMetadata)).toBe(true); + }); + + it('should return false for invalid responses', () => { + expect(isConversationResponse(null)).toBe(false); + expect(isConversationResponse(undefined)).toBe(false); + expect(isConversationResponse('string')).toBe(false); + expect(isConversationResponse(123)).toBe(false); + expect(isConversationResponse({})).toBe(false); + expect(isConversationResponse({ object: 'thread' })).toBe(false); + expect(isConversationResponse({ object: 'response' })).toBe(false); + expect(isConversationResponse({ object: null })).toBe(false); + }); + }); });