Skip to content

Commit d0bbc1a

Browse files
authored
ref(core): Simplify addResponseAttributes in openai integration (#20013)
`addResponseAttributes` in the openai integration had a lot of convoluted special handling for setting attributes across different OpenAI APIs (chat completions, responses API, embeddings, conversations). To reduce complexity, this inlines everything into one helper that simply sets attributes if they are present on the response, rather than branching by API type.
1 parent 6a397a3 commit d0bbc1a

File tree

3 files changed

+83
-309
lines changed

3 files changed

+83
-309
lines changed

packages/core/src/tracing/openai/index.ts

Lines changed: 2 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
GEN_AI_OPERATION_NAME_ATTRIBUTE,
1313
GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE,
1414
GEN_AI_REQUEST_MODEL_ATTRIBUTE,
15-
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
1615
GEN_AI_SYSTEM_ATTRIBUTE,
1716
GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE,
1817
} from '../ai/gen-ai-attributes';
@@ -26,18 +25,8 @@ import {
2625
} from '../ai/utils';
2726
import { OPENAI_METHOD_REGISTRY } from './constants';
2827
import { instrumentStream } from './streaming';
29-
import type { ChatCompletionChunk, OpenAiOptions, OpenAiResponse, OpenAIStream, ResponseStreamingEvent } from './types';
30-
import {
31-
addChatCompletionAttributes,
32-
addConversationAttributes,
33-
addEmbeddingsAttributes,
34-
addResponsesApiAttributes,
35-
extractRequestParameters,
36-
isChatCompletionResponse,
37-
isConversationResponse,
38-
isEmbeddingsResponse,
39-
isResponsesApiResponse,
40-
} from './utils';
28+
import type { ChatCompletionChunk, OpenAiOptions, OpenAIStream, ResponseStreamingEvent } from './types';
29+
import { addResponseAttributes, extractRequestParameters } from './utils';
4130

4231
/**
4332
* Extract available tools from request parameters
@@ -88,33 +77,6 @@ function extractRequestAttributes(args: unknown[], operationName: string): Recor
8877
return attributes;
8978
}
9079

91-
/**
92-
* Add response attributes to spans
93-
* This supports Chat Completion, Responses API, Embeddings, and Conversations API responses
94-
*/
95-
function addResponseAttributes(span: Span, result: unknown, recordOutputs?: boolean): void {
96-
if (!result || typeof result !== 'object') return;
97-
98-
const response = result as OpenAiResponse;
99-
100-
if (isChatCompletionResponse(response)) {
101-
addChatCompletionAttributes(span, response, recordOutputs);
102-
if (recordOutputs && response.choices?.length) {
103-
const responseTexts = response.choices.map(choice => choice.message?.content || '');
104-
span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: JSON.stringify(responseTexts) });
105-
}
106-
} else if (isResponsesApiResponse(response)) {
107-
addResponsesApiAttributes(span, response, recordOutputs);
108-
if (recordOutputs && response.output_text) {
109-
span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.output_text });
110-
}
111-
} else if (isEmbeddingsResponse(response)) {
112-
addEmbeddingsAttributes(span, response);
113-
} else if (isConversationResponse(response)) {
114-
addConversationAttributes(span, response);
115-
}
116-
}
117-
11880
// Extract and record AI request inputs, if present. This is intentionally separate from response attributes.
11981
function addRequestAttributes(span: Span, params: Record<string, unknown>, operationName: string): void {
12082
// Store embeddings input on a separate attribute and do not truncate it

packages/core/src/tracing/openai/utils.ts

Lines changed: 80 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Span } from '../../types-hoist/span';
2+
import type { SpanAttributeValue } from '../../types-hoist/span';
23
import {
34
GEN_AI_CONVERSATION_ID_ATTRIBUTE,
45
GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE,
@@ -12,71 +13,13 @@ import {
1213
GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE,
1314
GEN_AI_RESPONSE_ID_ATTRIBUTE,
1415
GEN_AI_RESPONSE_MODEL_ATTRIBUTE,
16+
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
1517
GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE,
1618
GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE,
1719
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
1820
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
1921
} from '../ai/gen-ai-attributes';
20-
import type {
21-
ChatCompletionChunk,
22-
OpenAiChatCompletionObject,
23-
OpenAIConversationObject,
24-
OpenAICreateEmbeddingsObject,
25-
OpenAIResponseObject,
26-
ResponseStreamingEvent,
27-
} from './types';
28-
29-
/**
30-
* Check if response is a Chat Completion object
31-
*/
32-
export function isChatCompletionResponse(response: unknown): response is OpenAiChatCompletionObject {
33-
return (
34-
response !== null &&
35-
typeof response === 'object' &&
36-
'object' in response &&
37-
(response as Record<string, unknown>).object === 'chat.completion'
38-
);
39-
}
40-
41-
/**
42-
* Check if response is a Responses API object
43-
*/
44-
export function isResponsesApiResponse(response: unknown): response is OpenAIResponseObject {
45-
return (
46-
response !== null &&
47-
typeof response === 'object' &&
48-
'object' in response &&
49-
(response as Record<string, unknown>).object === 'response'
50-
);
51-
}
52-
53-
/**
54-
* Check if response is an Embeddings API object
55-
*/
56-
export function isEmbeddingsResponse(response: unknown): response is OpenAICreateEmbeddingsObject {
57-
if (response === null || typeof response !== 'object' || !('object' in response)) {
58-
return false;
59-
}
60-
const responseObject = response as Record<string, unknown>;
61-
return (
62-
responseObject.object === 'list' &&
63-
typeof responseObject.model === 'string' &&
64-
responseObject.model.toLowerCase().includes('embedding')
65-
);
66-
}
67-
68-
/**
69-
* Check if response is a Conversations API object
70-
* @see https://platform.openai.com/docs/api-reference/conversations
71-
*/
72-
export function isConversationResponse(response: unknown): response is OpenAIConversationObject {
73-
return (
74-
response !== null &&
75-
typeof response === 'object' &&
76-
'object' in response &&
77-
(response as Record<string, unknown>).object === 'conversation'
78-
);
79-
}
22+
import type { ChatCompletionChunk, ResponseStreamingEvent } from './types';
8023

8124
/**
8225
* Check if streaming event is from the Responses API
@@ -104,157 +47,108 @@ export function isChatCompletionChunk(event: unknown): event is ChatCompletionCh
10447
}
10548

10649
/**
107-
* Add attributes for Chat Completion responses
50+
* Add response attributes to a span using duck-typing.
51+
* Works for Chat Completions, Responses API, Embeddings, and Conversations API responses.
10852
*/
109-
export function addChatCompletionAttributes(
110-
span: Span,
111-
response: OpenAiChatCompletionObject,
112-
recordOutputs?: boolean,
113-
): void {
114-
setCommonResponseAttributes(span, response.id, response.model);
115-
if (response.usage) {
116-
setTokenUsageAttributes(
117-
span,
118-
response.usage.prompt_tokens,
119-
response.usage.completion_tokens,
120-
response.usage.total_tokens,
121-
);
53+
export function addResponseAttributes(span: Span, result: unknown, recordOutputs?: boolean): void {
54+
if (!result || typeof result !== 'object') return;
55+
56+
const response = result as Record<string, unknown>;
57+
const attrs: Record<string, SpanAttributeValue> = {};
58+
59+
// Response ID
60+
if (typeof response.id === 'string') {
61+
attrs[GEN_AI_RESPONSE_ID_ATTRIBUTE] = response.id;
12262
}
63+
64+
// Response model
65+
if (typeof response.model === 'string') {
66+
attrs[GEN_AI_RESPONSE_MODEL_ATTRIBUTE] = response.model;
67+
}
68+
69+
// Conversation ID (conversation objects use id as conversation link)
70+
if (response.object === 'conversation' && typeof response.id === 'string') {
71+
attrs[GEN_AI_CONVERSATION_ID_ATTRIBUTE] = response.id;
72+
}
73+
74+
// Token usage — supports both naming conventions (chat: prompt_tokens/completion_tokens, responses: input_tokens/output_tokens)
75+
if (response.usage && typeof response.usage === 'object') {
76+
const usage = response.usage as Record<string, unknown>;
77+
78+
const inputTokens = usage.prompt_tokens ?? usage.input_tokens;
79+
if (typeof inputTokens === 'number') {
80+
attrs[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE] = inputTokens;
81+
}
82+
83+
const outputTokens = usage.completion_tokens ?? usage.output_tokens;
84+
if (typeof outputTokens === 'number') {
85+
attrs[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] = outputTokens;
86+
}
87+
88+
if (typeof usage.total_tokens === 'number') {
89+
attrs[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE] = usage.total_tokens;
90+
}
91+
}
92+
93+
// Finish reasons from choices (chat completions)
12394
if (Array.isArray(response.choices)) {
124-
const finishReasons = response.choices
95+
const choices = response.choices as Array<Record<string, unknown>>;
96+
const finishReasons = choices
12597
.map(choice => choice.finish_reason)
126-
.filter((reason): reason is string => reason !== null);
98+
.filter((reason): reason is string => typeof reason === 'string');
12799
if (finishReasons.length > 0) {
128-
span.setAttributes({
129-
[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify(finishReasons),
130-
});
100+
attrs[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE] = JSON.stringify(finishReasons);
131101
}
132102

133-
// Extract tool calls from all choices (only if recordOutputs is true)
134103
if (recordOutputs) {
135-
const toolCalls = response.choices
136-
.map(choice => choice.message?.tool_calls)
104+
// Response text from choices
105+
const responseTexts = choices.map(choice => {
106+
const message = choice.message as Record<string, unknown> | undefined;
107+
return (message?.content as string) || '';
108+
});
109+
attrs[GEN_AI_RESPONSE_TEXT_ATTRIBUTE] = JSON.stringify(responseTexts);
110+
111+
// Tool calls from choices
112+
const toolCalls = choices
113+
.map(choice => {
114+
const message = choice.message as Record<string, unknown> | undefined;
115+
return message?.tool_calls;
116+
})
137117
.filter(calls => Array.isArray(calls) && calls.length > 0)
138118
.flat();
139119

140120
if (toolCalls.length > 0) {
141-
span.setAttributes({
142-
[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(toolCalls),
143-
});
121+
attrs[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] = JSON.stringify(toolCalls);
144122
}
145123
}
146124
}
147-
}
148125

149-
/**
150-
* Add attributes for Responses API responses
151-
*/
152-
export function addResponsesApiAttributes(span: Span, response: OpenAIResponseObject, recordOutputs?: boolean): void {
153-
setCommonResponseAttributes(span, response.id, response.model);
154-
if (response.status) {
155-
span.setAttributes({
156-
[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify([response.status]),
157-
});
158-
}
159-
if (response.usage) {
160-
setTokenUsageAttributes(
161-
span,
162-
response.usage.input_tokens,
163-
response.usage.output_tokens,
164-
response.usage.total_tokens,
165-
);
126+
// Finish reason from status (responses API)
127+
if (typeof response.status === 'string') {
128+
// Only set if not already set from choices
129+
if (!attrs[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]) {
130+
attrs[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE] = JSON.stringify([response.status]);
131+
}
166132
}
167133

168-
// Extract function calls from output (only if recordOutputs is true)
169134
if (recordOutputs) {
170-
const responseWithOutput = response as OpenAIResponseObject & { output?: unknown[] };
171-
if (Array.isArray(responseWithOutput.output) && responseWithOutput.output.length > 0) {
172-
// Filter for function_call type objects in the output array
173-
const functionCalls = responseWithOutput.output.filter(
174-
(item): unknown =>
175-
// oxlint-disable-next-line typescript/prefer-optional-chain
176-
typeof item === 'object' && item !== null && (item as Record<string, unknown>).type === 'function_call',
177-
);
135+
// Response text from output_text (responses API)
136+
if (typeof response.output_text === 'string' && !attrs[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]) {
137+
attrs[GEN_AI_RESPONSE_TEXT_ATTRIBUTE] = response.output_text;
138+
}
178139

140+
// Tool calls from output array (responses API)
141+
if (Array.isArray(response.output) && response.output.length > 0 && !attrs[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]) {
142+
const functionCalls = (response.output as Array<Record<string, unknown>>).filter(
143+
item => item?.type === 'function_call',
144+
);
179145
if (functionCalls.length > 0) {
180-
span.setAttributes({
181-
[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(functionCalls),
182-
});
146+
attrs[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] = JSON.stringify(functionCalls);
183147
}
184148
}
185149
}
186-
}
187-
188-
/**
189-
* Add attributes for Embeddings API responses
190-
*/
191-
export function addEmbeddingsAttributes(span: Span, response: OpenAICreateEmbeddingsObject): void {
192-
span.setAttributes({
193-
[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: response.model,
194-
});
195150

196-
if (response.usage) {
197-
setTokenUsageAttributes(span, response.usage.prompt_tokens, undefined, response.usage.total_tokens);
198-
}
199-
}
200-
201-
/**
202-
* Add attributes for Conversations API responses
203-
* @see https://platform.openai.com/docs/api-reference/conversations
204-
*/
205-
export function addConversationAttributes(span: Span, response: OpenAIConversationObject): void {
206-
const { id } = response;
207-
208-
span.setAttributes({
209-
[GEN_AI_RESPONSE_ID_ATTRIBUTE]: id,
210-
// The conversation id is used to link messages across API calls
211-
[GEN_AI_CONVERSATION_ID_ATTRIBUTE]: id,
212-
});
213-
}
214-
215-
/**
216-
* Set token usage attributes
217-
* @param span - The span to add attributes to
218-
* @param promptTokens - The number of prompt tokens
219-
* @param completionTokens - The number of completion tokens
220-
* @param totalTokens - The number of total tokens
221-
*/
222-
export function setTokenUsageAttributes(
223-
span: Span,
224-
promptTokens?: number,
225-
completionTokens?: number,
226-
totalTokens?: number,
227-
): void {
228-
if (promptTokens !== undefined) {
229-
span.setAttributes({
230-
[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: promptTokens,
231-
});
232-
}
233-
if (completionTokens !== undefined) {
234-
span.setAttributes({
235-
[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: completionTokens,
236-
});
237-
}
238-
if (totalTokens !== undefined) {
239-
span.setAttributes({
240-
[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: totalTokens,
241-
});
242-
}
243-
}
244-
245-
/**
246-
* Set common response attributes
247-
* @param span - The span to add attributes to
248-
* @param id - The response id
249-
* @param model - The response model
250-
*/
251-
export function setCommonResponseAttributes(span: Span, id: string, model: string): void {
252-
span.setAttributes({
253-
[GEN_AI_RESPONSE_ID_ATTRIBUTE]: id,
254-
});
255-
span.setAttributes({
256-
[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: model,
257-
});
151+
span.setAttributes(attrs);
258152
}
259153

260154
/**

0 commit comments

Comments
 (0)