@@ -377,27 +377,6 @@ export class FetchInstrumentation extends InstrumentationBase<FetchInstrumentati
377377 private _patchConstructor ( ) : ( original : typeof fetch ) => typeof fetch {
378378 return original => {
379379 const plugin = this ;
380- const readOnlyProps = new Set ( [ 'url' , 'type' , 'redirected' ] ) ;
381- function createResponseProxy (
382- target : Response ,
383- originalResponse : Response
384- ) : Response {
385- return new Proxy ( target , {
386- get ( t , prop , _receiver ) {
387- if ( typeof prop === 'string' && readOnlyProps . has ( prop ) ) {
388- return Reflect . get ( originalResponse , prop ) ;
389- }
390- if ( prop === 'clone' ) {
391- return function clone ( ) {
392- return createResponseProxy ( t . clone ( ) , originalResponse ) ;
393- } ;
394- }
395- // Use target as receiver so getters (e.g. headers) run with correct this and avoid "Illegal invocation"
396- const value = Reflect . get ( t , prop , t ) ;
397- return typeof value === 'function' ? value . bind ( t ) : value ;
398- } ,
399- } ) as Response ;
400- }
401380
402381 return function patchConstructor (
403382 this : typeof globalThis ,
@@ -459,81 +438,20 @@ export class FetchInstrumentation extends InstrumentationBase<FetchInstrumentati
459438 }
460439 }
461440
462- function withCancelPropagation (
463- body : ReadableStream < Uint8Array > | null ,
464- readerClone : ReadableStreamDefaultReader < Uint8Array >
465- ) : ReadableStream < Uint8Array > | null {
466- if ( ! body ) return null ;
467-
468- const reader = body . getReader ( ) ;
469-
470- return new ReadableStream ( {
471- async pull ( controller ) {
472- try {
473- const { value, done } = await reader . read ( ) ;
474- if ( done ) {
475- reader . releaseLock ( ) ;
476- controller . close ( ) ;
477- } else {
478- controller . enqueue ( value ) ;
479- }
480- } catch ( err ) {
481- controller . error ( err ) ;
482- reader . cancel ( err ) . catch ( _ => { } ) ;
483-
484- try {
485- reader . releaseLock ( ) ;
486- } catch {
487- // Spec reference:
488- // https://streams.spec.whatwg.org/#default-reader-release-lock
489- //
490- // releaseLock() only throws if called on an invalid reader
491- // (i.e. reader.[[stream]] is undefined, meaning the lock is already released
492- // or the reader was never associated). In normal use this cannot happen.
493- // This catch is defensive only.
494- }
495- }
496- } ,
497- cancel ( reason ) {
498- readerClone . cancel ( reason ) . catch ( _ => { } ) ;
499- return reader . cancel ( reason ) ;
500- } ,
501- } ) ;
502- }
503-
504- function onSuccess (
505- span : Span ,
506- resolve : ( value : Response | PromiseLike < Response > ) => void ,
507- response : Response
508- ) : void {
509- let proxiedResponse : Response | null = null ;
510-
441+ function onSuccess ( span : Span , response : Response ) : Response {
511442 try {
443+ // Clone the response and eagerly consume the clone to detect
444+ // when the body transfer completes. The original response is
445+ // returned to the caller untouched so that it passes internal
446+ // brand-checks required by APIs such as
447+ // WebAssembly.compileStreaming.
512448 // TODO: Switch to a consumer-driven model and drop `resClone`.
513449 // Keeping eager consumption here to preserve current behavior and avoid breaking existing tests.
514450 // Context: discussion in PR #5894 → https://github.com/open-telemetry/opentelemetry-js/pull/5894
515451 const resClone = response . clone ( ) ;
516452 const body = resClone . body ;
517453 if ( body ) {
518454 const reader = body . getReader ( ) ;
519- const isNullBodyStatus =
520- // 101 responses and protocol upgrading is handled internally by the browser
521- response . status === 204 ||
522- response . status === 205 ||
523- response . status === 304 ;
524- const wrappedBody = isNullBodyStatus
525- ? null
526- : withCancelPropagation ( response . body , reader ) ;
527-
528- const newResponse = new Response ( wrappedBody , {
529- status : response . status ,
530- statusText : response . statusText ,
531- headers : response . headers ,
532- } ) ;
533-
534- // Response url, type, and redirected are read-only properties that can't be set via constructor
535- // Use a Proxy to forward them from the original response and maintain the wrapped body
536- proxiedResponse = createResponseProxy ( newResponse , response ) ;
537455
538456 const read = ( ) : void => {
539457 reader . read ( ) . then (
@@ -554,46 +472,55 @@ export class FetchInstrumentation extends InstrumentationBase<FetchInstrumentati
554472 // some older browsers don't have .body implemented
555473 endSpanOnSuccess ( span , response ) ;
556474 }
557- } finally {
558- resolve ( proxiedResponse ?? response ) ;
475+ } catch ( error ) {
476+ // Setup failed (e.g. clone() or getReader() threw).
477+ // End the span and clean up so _tasksCount doesn't leak.
478+ plugin . _diag . error ( 'Failed to read fetch response body' , error ) ;
479+ plugin . _endSpan ( span , spanData , {
480+ status : 0 ,
481+ url,
482+ } ) ;
559483 }
484+ return response ;
560485 }
561486
562- function onError (
563- span : Span ,
564- reject : ( reason ?: unknown ) => void ,
565- error : FetchError
566- ) {
487+ function onError ( span : Span , error : FetchError ) : never {
567488 try {
568489 endSpanOnError ( span , error ) ;
569- } finally {
570- reject ( error ) ;
490+ } catch ( e : unknown ) {
491+ // endSpanOnError failed — fall back to ending the span
492+ // directly so _tasksCount doesn't leak.
493+ plugin . _diag . error ( 'Failed to end span on fetch error' , e ) ;
494+ plugin . _endSpan ( span , spanData , {
495+ status : error . status || 0 ,
496+ url,
497+ } ) ;
571498 }
499+ // eslint-disable-next-line @typescript-eslint/only-throw-error
500+ throw error ;
572501 }
573502
574- return new Promise ( ( resolve , reject ) => {
575- return context . with (
576- trace . setSpan ( context . active ( ) , createdSpan ) ,
577- ( ) => {
578- // Call request hook before injection so hooks cannot tamper with propagation headers.
579- // Also, this means the hook will see `options.headers` in the same type as passed in,
580- // rather than as a `Headers` instance set by `_addHeaders()`.
581- plugin . _callRequestHook ( createdSpan , options ) ;
582- plugin . _addHeaders ( options , url ) ;
583- plugin . _tasksCount ++ ;
584-
585- return original
586- . apply (
587- self ,
588- options instanceof Request ? [ options ] : [ url , options ]
589- )
590- . then (
591- onSuccess . bind ( self , createdSpan , resolve ) ,
592- onError . bind ( self , createdSpan , reject )
593- ) ;
594- }
595- ) ;
596- } ) ;
503+ return context . with (
504+ trace . setSpan ( context . active ( ) , createdSpan ) ,
505+ ( ) => {
506+ // Call request hook before injection so hooks cannot tamper with propagation headers.
507+ // Also, this means the hook will see `options.headers` in the same type as passed in,
508+ // rather than as a `Headers` instance set by `_addHeaders()`.
509+ plugin . _callRequestHook ( createdSpan , options ) ;
510+ plugin . _addHeaders ( options , url ) ;
511+ plugin . _tasksCount ++ ;
512+
513+ return original
514+ . apply (
515+ self ,
516+ options instanceof Request ? [ options ] : [ url , options ]
517+ )
518+ . then (
519+ onSuccess . bind ( self , createdSpan ) ,
520+ onError . bind ( self , createdSpan )
521+ ) ;
522+ }
523+ ) ;
597524 } ;
598525 } ;
599526 }
0 commit comments