Skip to content

Commit 009e829

Browse files
committed
fix(openai): preserve .withResponse() on create() return value
1 parent 46ad70e commit 009e829

1 file changed

Lines changed: 135 additions & 78 deletions

File tree

  • packages/core/src/tracing/openai

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

Lines changed: 135 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -162,95 +162,152 @@ function addRequestAttributes(span: Span, params: Record<string, unknown>, opera
162162
}
163163

164164
/**
165-
* Instrument a method with Sentry spans
166-
* Following Sentry AI Agents Manual Instrumentation conventions
165+
* Wrap the original return value so span logic runs on settle while preserving
166+
* API surface (e.g. .withResponse()). Callers can await the wrapper or call .withResponse().
167+
*/
168+
function wrapReturnValue<R>(
169+
result: R & { then?: (onFulfilled?: (value: unknown) => unknown, onRejected?: (reason?: unknown) => unknown) => unknown; withResponse?: () => unknown },
170+
span: Span,
171+
options: { recordOutputs?: boolean; recordInputs?: boolean },
172+
methodPath: InstrumentedMethod,
173+
params: Record<string, unknown> | undefined,
174+
operationName: string,
175+
isStreamRequested: boolean,
176+
): R {
177+
const thenable =
178+
result !== null &&
179+
typeof result === 'object' &&
180+
typeof (result as { then?: unknown }).then === 'function'
181+
? (result as unknown as Promise<unknown>)
182+
: Promise.resolve(result);
183+
184+
const chained = isStreamRequested
185+
? thenable.then(
186+
(stream: unknown) =>
187+
instrumentStream(
188+
stream as OpenAIStream<ChatCompletionChunk | ResponseStreamingEvent>,
189+
span,
190+
options.recordOutputs ?? false,
191+
),
192+
(error: unknown) => {
193+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
194+
captureException(error, {
195+
mechanism: { handled: false, type: 'auto.ai.openai.stream', data: { function: methodPath } },
196+
});
197+
span.end();
198+
throw error;
199+
},
200+
)
201+
: thenable.then(
202+
(data: unknown) => {
203+
addResponseAttributes(span, data, options.recordOutputs);
204+
span.end();
205+
return data;
206+
},
207+
(error: unknown) => {
208+
captureException(error, {
209+
mechanism: { handled: false, type: 'auto.ai.openai', data: { function: methodPath } },
210+
});
211+
span.end();
212+
throw error;
213+
},
214+
);
215+
216+
const wrapper = {
217+
then(onFulfilled?: (value: unknown) => unknown, onRejected?: (reason?: unknown) => unknown) {
218+
return chained.then(onFulfilled, onRejected);
219+
},
220+
catch(onRejected?: (reason?: unknown) => unknown) {
221+
return chained.catch(onRejected);
222+
},
223+
finally(onFinally?: () => void) {
224+
return chained.finally(onFinally);
225+
},
226+
} as unknown as R & { withResponse?: () => unknown };
227+
228+
if (typeof result === 'object' && result !== null && typeof (result as { withResponse?: () => unknown }).withResponse === 'function') {
229+
const withResponseOriginal = (result as { withResponse: () => unknown }).withResponse;
230+
wrapper.withResponse = function withResponse() {
231+
const withResponseResult = withResponseOriginal.call(result);
232+
const withResponseThenable =
233+
withResponseResult !== null &&
234+
typeof withResponseResult === 'object' &&
235+
typeof (withResponseResult as { then?: unknown }).then === 'function'
236+
? (withResponseResult as Promise<{ data: AsyncIterable<unknown>; response: unknown }>)
237+
: Promise.resolve(withResponseResult);
238+
239+
if (isStreamRequested) {
240+
return withResponseThenable.then((payload: { data: AsyncIterable<unknown>; response: unknown }) => ({
241+
data: instrumentStream(
242+
payload.data as OpenAIStream<ChatCompletionChunk | ResponseStreamingEvent>,
243+
span,
244+
options.recordOutputs ?? false,
245+
),
246+
response: payload.response,
247+
}));
248+
}
249+
return withResponseThenable.then((payload: { data: unknown; response: unknown }) => {
250+
addResponseAttributes(span, payload.data, options.recordOutputs);
251+
return payload;
252+
});
253+
};
254+
}
255+
256+
return wrapper as R;
257+
}
258+
259+
/**
260+
* Instrument a method with Sentry spans. Returns the same shape as the original
261+
* (including .withResponse() when present) and runs span logic when the promise settles.
167262
* @see https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/ai-agents-module/#manual-instrumentation
168263
*/
169264
function instrumentMethod<T extends unknown[], R>(
170265
originalMethod: (...args: T) => Promise<R>,
171266
methodPath: InstrumentedMethod,
172267
context: unknown,
173268
options: OpenAiOptions,
174-
): (...args: T) => Promise<R> {
175-
return async function instrumentedMethod(...args: T): Promise<R> {
269+
): (...args: T) => R {
270+
return function instrumentedMethod(...args: T): R {
176271
const requestAttributes = extractRequestAttributes(args, methodPath);
177272
const model = (requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] as string) || 'unknown';
178273
const operationName = getOperationName(methodPath);
179-
180274
const params = args[0] as Record<string, unknown> | undefined;
181-
const isStreamRequested = params && typeof params === 'object' && params.stream === true;
182-
183-
if (isStreamRequested) {
184-
// For streaming responses, use manual span management to properly handle the async generator lifecycle
185-
return startSpanManual(
186-
{
187-
name: `${operationName} ${model} stream-response`,
188-
op: getSpanOperation(methodPath),
189-
attributes: requestAttributes as Record<string, SpanAttributeValue>,
190-
},
191-
async (span: Span) => {
192-
try {
193-
if (options.recordInputs && params) {
194-
addRequestAttributes(span, params, operationName);
195-
}
196-
197-
const result = await originalMethod.apply(context, args);
198-
199-
return instrumentStream(
200-
result as OpenAIStream<ChatCompletionChunk | ResponseStreamingEvent>,
201-
span,
202-
options.recordOutputs ?? false,
203-
) as unknown as R;
204-
} catch (error) {
205-
// For streaming requests that fail before stream creation, we still want to record
206-
// them as streaming requests but end the span gracefully
207-
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
208-
captureException(error, {
209-
mechanism: {
210-
handled: false,
211-
type: 'auto.ai.openai.stream',
212-
data: {
213-
function: methodPath,
214-
},
215-
},
216-
});
217-
span.end();
218-
throw error;
219-
}
220-
},
221-
);
222-
} else {
223-
// Non-streaming responses
224-
return startSpan(
225-
{
226-
name: `${operationName} ${model}`,
227-
op: getSpanOperation(methodPath),
228-
attributes: requestAttributes as Record<string, SpanAttributeValue>,
229-
},
230-
async (span: Span) => {
231-
try {
232-
if (options.recordInputs && params) {
233-
addRequestAttributes(span, params, operationName);
234-
}
235-
236-
const result = await originalMethod.apply(context, args);
237-
addResponseAttributes(span, result, options.recordOutputs);
238-
return result;
239-
} catch (error) {
240-
captureException(error, {
241-
mechanism: {
242-
handled: false,
243-
type: 'auto.ai.openai',
244-
data: {
245-
function: methodPath,
246-
},
247-
},
248-
});
249-
throw error;
250-
}
251-
},
252-
);
253-
}
275+
const isStreamRequested = !!(params && typeof params === 'object' && params.stream === true);
276+
277+
const spanOptions = {
278+
name: isStreamRequested ? `${operationName} ${model} stream-response` : `${operationName} ${model}`,
279+
op: getSpanOperation(methodPath),
280+
attributes: requestAttributes as Record<string, SpanAttributeValue>,
281+
};
282+
283+
return startSpanManual(spanOptions, (span: Span) => {
284+
if (options.recordInputs && params) {
285+
addRequestAttributes(span, params, operationName);
286+
}
287+
let result: R & {
288+
then?: (onFulfilled?: (value: unknown) => unknown, onRejected?: (reason?: unknown) => unknown) => unknown;
289+
withResponse?: () => unknown;
290+
};
291+
try {
292+
result = originalMethod.apply(context, args) as typeof result;
293+
} catch (error) {
294+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
295+
captureException(error, {
296+
mechanism: { handled: false, type: 'auto.ai.openai', data: { function: methodPath } },
297+
});
298+
span.end();
299+
throw error;
300+
}
301+
return wrapReturnValue(
302+
result,
303+
span,
304+
options,
305+
methodPath,
306+
params,
307+
operationName,
308+
isStreamRequested,
309+
) as R;
310+
});
254311
};
255312
}
256313

0 commit comments

Comments
 (0)