Skip to content

Commit b8318c0

Browse files
committed
refactor tests, and add more docs
1 parent 2dffa0e commit b8318c0

File tree

5 files changed

+136
-60
lines changed

5 files changed

+136
-60
lines changed

dev-packages/node-integration-tests/suites/tracing/openai/scenario.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,11 @@ class MockOpenAI {
6161
return {
6262
id: 'resp_mock456',
6363
object: 'response',
64-
created: 1677652290,
64+
created_at: 1677652290,
6565
model: params.model,
6666
input_text: params.input,
6767
output_text: `Response to: ${params.input}`,
68-
finish_reason: 'stop',
68+
status: 'completed',
6969
usage: {
7070
input_tokens: 5,
7171
output_tokens: 8,
@@ -260,7 +260,7 @@ async function run() {
260260
instructions: 'You are a translator',
261261
});
262262

263-
// Third test: error handling
263+
// Third test: error handling in chat completions
264264
try {
265265
await client.chat.completions.create({
266266
model: 'error-model',

dev-packages/node-integration-tests/suites/tracing/openai/test.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,13 @@ describe('OpenAI integration', () => {
4545
'gen_ai.request.model': 'gpt-3.5-turbo',
4646
'gen_ai.response.model': 'gpt-3.5-turbo',
4747
'gen_ai.response.id': 'resp_mock456',
48+
'gen_ai.response.finish_reasons': '["completed"]',
4849
'gen_ai.usage.input_tokens': 5,
4950
'gen_ai.usage.output_tokens': 8,
5051
'gen_ai.usage.total_tokens': 13,
5152
'openai.response.id': 'resp_mock456',
5253
'openai.response.model': 'gpt-3.5-turbo',
54+
'openai.response.timestamp': '2023-03-01T06:31:30.000Z',
5355
'openai.usage.completion_tokens': 8,
5456
'openai.usage.prompt_tokens': 5,
5557
},
@@ -90,6 +92,7 @@ describe('OpenAI integration', () => {
9092
'gen_ai.usage.total_tokens': 30,
9193
'openai.response.id': 'chatcmpl-stream-123',
9294
'openai.response.model': 'gpt-4',
95+
'openai.response.stream': true,
9396
'openai.response.timestamp': '2023-03-01T06:31:40.000Z',
9497
'openai.usage.completion_tokens': 18,
9598
'openai.usage.prompt_tokens': 12,
@@ -110,21 +113,37 @@ describe('OpenAI integration', () => {
110113
'gen_ai.request.stream': true,
111114
'gen_ai.response.model': 'gpt-4',
112115
'gen_ai.response.id': 'resp_stream_456',
113-
'gen_ai.response.finish_reasons': '["in_progress"]',
114-
'gen_ai.usage.input_tokens': 0,
115-
'gen_ai.usage.output_tokens': 0,
116-
'gen_ai.usage.total_tokens': 0,
116+
'gen_ai.response.finish_reasons': '["in_progress","completed"]',
117+
'gen_ai.usage.input_tokens': 6,
118+
'gen_ai.usage.output_tokens': 10,
119+
'gen_ai.usage.total_tokens': 16,
117120
'openai.response.id': 'resp_stream_456',
118121
'openai.response.model': 'gpt-4',
122+
'openai.response.stream': true,
119123
'openai.response.timestamp': '2023-03-01T06:31:50.000Z',
120-
'openai.usage.completion_tokens': 0,
121-
'openai.usage.prompt_tokens': 0,
124+
'openai.usage.completion_tokens': 10,
125+
'openai.usage.prompt_tokens': 6,
122126
},
123127
description: 'chat gpt-4 stream-response',
124128
op: 'gen_ai.chat',
125129
origin: 'manual',
126130
status: 'ok',
127131
}),
132+
// Sixth span - error handling in streaming context
133+
expect.objectContaining({
134+
data: {
135+
'gen_ai.operation.name': 'chat',
136+
'gen_ai.request.model': 'error-model',
137+
'gen_ai.request.stream': true,
138+
'gen_ai.system': 'openai',
139+
'sentry.op': 'gen_ai.chat',
140+
'sentry.origin': 'manual',
141+
},
142+
description: 'chat error-model stream-response',
143+
op: 'gen_ai.chat',
144+
origin: 'manual',
145+
status: 'ok',
146+
}),
128147
]),
129148
};
130149

@@ -170,13 +189,15 @@ describe('OpenAI integration', () => {
170189
'gen_ai.request.model': 'gpt-3.5-turbo',
171190
'gen_ai.request.messages': '"Translate this to French: Hello"',
172191
'gen_ai.response.text': 'Response to: Translate this to French: Hello',
192+
'gen_ai.response.finish_reasons': '["completed"]',
173193
'gen_ai.response.model': 'gpt-3.5-turbo',
174194
'gen_ai.response.id': 'resp_mock456',
175195
'gen_ai.usage.input_tokens': 5,
176196
'gen_ai.usage.output_tokens': 8,
177197
'gen_ai.usage.total_tokens': 13,
178198
'openai.response.id': 'resp_mock456',
179199
'openai.response.model': 'gpt-3.5-turbo',
200+
'openai.response.timestamp': '2023-03-01T06:31:30.000Z',
180201
'openai.usage.completion_tokens': 8,
181202
'openai.usage.prompt_tokens': 5,
182203
},
@@ -268,7 +289,6 @@ describe('OpenAI integration', () => {
268289
'gen_ai.request.stream': true,
269290
'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]',
270291
'gen_ai.system': 'openai',
271-
'openai.response.stream': true,
272292
'sentry.op': 'gen_ai.chat',
273293
'sentry.origin': 'manual',
274294
},

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE,
1515
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
1616
GEN_AI_SYSTEM_ATTRIBUTE,
17-
OPENAI_RESPONSE_STREAM_ATTRIBUTE,
1817
} from '../gen-ai-attributes';
1918
import { OPENAI_INTEGRATION_NAME } from './constants';
2019
import { instrumentStream } from './streaming';
@@ -144,7 +143,7 @@ function addRequestAttributes(span: Span, params: Record<string, unknown>): void
144143
span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) });
145144
}
146145
if ('stream' in params) {
147-
span.setAttributes({ [OPENAI_RESPONSE_STREAM_ATTRIBUTE]: Boolean(params.stream) });
146+
span.setAttributes({ [GEN_AI_REQUEST_STREAM_ATTRIBUTE]: Boolean(params.stream) });
148147
}
149148
}
150149

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

Lines changed: 93 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { captureException } from '../../exports';
2+
import { SPAN_STATUS_ERROR } from '../../tracing';
23
import type { Span } from '../../types-hoist/span';
3-
import { GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE } from '../gen-ai-attributes';
4+
import {
5+
GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE,
6+
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
7+
OPENAI_RESPONSE_STREAM_ATTRIBUTE,
8+
} from '../gen-ai-attributes';
49
import { RESPONSE_EVENT_TYPES } from './constants';
510
import type { OpenAIResponseObject } from './types';
611
import { type ChatCompletionChunk, type ResponseStreamingEvent } from './types';
@@ -11,24 +16,48 @@ import {
1116
setTokenUsageAttributes,
1217
} from './utils';
1318

19+
/**
20+
* State object used to accumulate information from a stream of OpenAI events/chunks.
21+
*/
1422
interface StreamingState {
23+
/** Types of events encountered in the stream. */
1524
eventTypes: string[];
25+
/** Collected response text fragments (for output recording). */
1626
responseTexts: string[];
27+
/** Reasons for finishing the response, as reported by the API. */
1728
finishReasons: string[];
18-
responseId?: string;
19-
responseModel?: string;
20-
responseTimestamp?: number;
21-
promptTokens?: number;
22-
completionTokens?: number;
23-
totalTokens?: number;
29+
/** The response ID. */
30+
responseId: string;
31+
/** The model name. */
32+
responseModel: string;
33+
/** The timestamp of the response. */
34+
responseTimestamp: number;
35+
/** Number of prompt/input tokens used. */
36+
promptTokens: number | undefined;
37+
/** Number of completion/output tokens used. */
38+
completionTokens: number | undefined;
39+
/** Total number of tokens used (prompt + completion). */
40+
totalTokens: number | undefined;
2441
}
2542

43+
/**
44+
* Processes a single OpenAI ChatCompletionChunk event, updating the streaming state.
45+
*
46+
* @param chunk - The ChatCompletionChunk event to process.
47+
* @param state - The current streaming state to update.
48+
* @param recordOutputs - Whether to record output text fragments.
49+
*/
2650
function processChatCompletionChunk(chunk: ChatCompletionChunk, state: StreamingState, recordOutputs: boolean): void {
2751
state.responseId = chunk.id ?? state.responseId;
2852
state.responseModel = chunk.model ?? state.responseModel;
2953
state.responseTimestamp = chunk.created ?? state.responseTimestamp;
3054

3155
if (chunk.usage) {
56+
// For stream responses, the input tokens remain constant across all events in the stream.
57+
// Output tokens, however, are only finalized in the last event.
58+
// Since we can't guarantee that the last event will include usage data or even be a typed event,
59+
// we update the output token values on every event that includes them.
60+
// This ensures that output token usage is always set, even if the final event lacks it.
3261
state.promptTokens = chunk.usage.prompt_tokens;
3362
state.completionTokens = chunk.usage.completion_tokens;
3463
state.totalTokens = chunk.usage.total_tokens;
@@ -44,17 +73,31 @@ function processChatCompletionChunk(chunk: ChatCompletionChunk, state: Streaming
4473
}
4574
}
4675

76+
/**
77+
* Processes a single OpenAI Responses API streaming event, updating the streaming state and span.
78+
*
79+
* @param streamEvent - The event to process (may be an error or unknown object).
80+
* @param state - The current streaming state to update.
81+
* @param recordOutputs - Whether to record output text fragments.
82+
* @param span - The span to update with error status if needed.
83+
*/
4784
function processResponsesApiEvent(
4885
streamEvent: ResponseStreamingEvent | unknown | Error,
4986
state: StreamingState,
5087
recordOutputs: boolean,
88+
span: Span,
5189
): void {
5290
if (!(streamEvent && typeof streamEvent === 'object')) {
5391
state.eventTypes.push('unknown:non-object');
5492
return;
5593
}
5694
if (streamEvent instanceof Error) {
57-
captureException(streamEvent);
95+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
96+
captureException(streamEvent, {
97+
mechanism: {
98+
handled: false,
99+
},
100+
});
58101
return;
59102
}
60103

@@ -71,32 +114,42 @@ function processResponsesApiEvent(
71114
return;
72115
}
73116

74-
const { response } = event as { response: OpenAIResponseObject };
75-
state.responseId = response.id ?? state.responseId;
76-
state.responseModel = response.model ?? state.responseModel;
77-
state.responseTimestamp = response.created_at ?? state.responseTimestamp;
78-
79-
if (response.usage) {
80-
state.promptTokens = response.usage.input_tokens;
81-
state.completionTokens = response.usage.output_tokens;
82-
state.totalTokens = response.usage.total_tokens;
83-
}
117+
if ('response' in event) {
118+
const { response } = event as { response: OpenAIResponseObject };
119+
state.responseId = response.id ?? state.responseId;
120+
state.responseModel = response.model ?? state.responseModel;
121+
state.responseTimestamp = response.created_at ?? state.responseTimestamp;
122+
123+
if (response.usage) {
124+
// For stream responses, the input tokens remain constant across all events in the stream.
125+
// Output tokens, however, are only finalized in the last event.
126+
// Since we can't guarantee that the last event will include usage data or even be a typed event,
127+
// we update the output token values on every event that includes them.
128+
// This ensures that output token usage is always set, even if the final event lacks it.
129+
state.promptTokens = response.usage.input_tokens;
130+
state.completionTokens = response.usage.output_tokens;
131+
state.totalTokens = response.usage.total_tokens;
132+
}
84133

85-
if (response.status) {
86-
state.finishReasons.push(response.status);
87-
}
134+
if (response.status) {
135+
state.finishReasons.push(response.status);
136+
}
88137

89-
if (recordOutputs && response.output_text) {
90-
state.responseTexts.push(response.output_text);
138+
if (recordOutputs && response.output_text) {
139+
state.responseTexts.push(response.output_text);
140+
}
91141
}
92142
}
143+
93144
/**
94-
* Instrument a stream of OpenAI events
95-
* @param stream - The stream of events to instrument
96-
* @param span - The span to add attributes to
97-
* @param recordOutputs - Whether to record outputs
98-
* @param finishSpan - Optional function to finish the span manually
99-
* @returns A generator that yields the events
145+
* Instruments a stream of OpenAI events, updating the provided span with relevant attributes and
146+
* optionally recording output text. This function yields each event from the input stream as it is processed.
147+
*
148+
* @template T - The type of events in the stream.
149+
* @param stream - The async iterable stream of events to instrument.
150+
* @param span - The span to add attributes to and to finish at the end of the stream.
151+
* @param recordOutputs - Whether to record output text fragments in the span.
152+
* @returns An async generator yielding each event from the input stream.
100153
*/
101154
export async function* instrumentStream<T>(
102155
stream: AsyncIterable<T>,
@@ -107,21 +160,31 @@ export async function* instrumentStream<T>(
107160
eventTypes: [],
108161
responseTexts: [],
109162
finishReasons: [],
163+
responseId: '',
164+
responseModel: '',
165+
responseTimestamp: 0,
166+
promptTokens: undefined,
167+
completionTokens: undefined,
168+
totalTokens: undefined,
110169
};
111170

112171
try {
113172
for await (const event of stream) {
114173
if (isChatCompletionChunk(event)) {
115174
processChatCompletionChunk(event as ChatCompletionChunk, state, recordOutputs);
116175
} else if (isResponsesApiStreamEvent(event)) {
117-
processResponsesApiEvent(event as ResponseStreamingEvent, state, recordOutputs);
176+
processResponsesApiEvent(event as ResponseStreamingEvent, state, recordOutputs, span);
118177
}
119178
yield event;
120179
}
121180
} finally {
122181
setCommonResponseAttributes(span, state.responseId, state.responseModel, state.responseTimestamp);
123182
setTokenUsageAttributes(span, state.promptTokens, state.completionTokens, state.totalTokens);
124183

184+
span.setAttributes({
185+
[OPENAI_RESPONSE_STREAM_ATTRIBUTE]: true,
186+
});
187+
125188
if (state.finishReasons.length) {
126189
span.setAttributes({
127190
[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify(state.finishReasons),

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

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -145,22 +145,16 @@ export function setTokenUsageAttributes(
145145
* @param model - The response model
146146
* @param timestamp - The response timestamp
147147
*/
148-
export function setCommonResponseAttributes(span: Span, id?: string, model?: string, timestamp?: number): void {
149-
if (id) {
150-
span.setAttributes({
151-
[OPENAI_RESPONSE_ID_ATTRIBUTE]: id,
152-
[GEN_AI_RESPONSE_ID_ATTRIBUTE]: id,
153-
});
154-
}
155-
if (model) {
156-
span.setAttributes({
157-
[OPENAI_RESPONSE_MODEL_ATTRIBUTE]: model,
158-
[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: model,
159-
});
160-
}
161-
if (timestamp) {
162-
span.setAttributes({
163-
[OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(timestamp * 1000).toISOString(),
164-
});
165-
}
148+
export function setCommonResponseAttributes(span: Span, id: string, model: string, timestamp: number): void {
149+
span.setAttributes({
150+
[OPENAI_RESPONSE_ID_ATTRIBUTE]: id,
151+
[GEN_AI_RESPONSE_ID_ATTRIBUTE]: id,
152+
});
153+
span.setAttributes({
154+
[OPENAI_RESPONSE_MODEL_ATTRIBUTE]: model,
155+
[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: model,
156+
});
157+
span.setAttributes({
158+
[OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(timestamp * 1000).toISOString(),
159+
});
166160
}

0 commit comments

Comments
 (0)