@@ -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 */
169264function 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