From e479578b6c23d4219036f270447855125f401d44 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 9 May 2026 10:25:36 +0200 Subject: [PATCH 1/3] feat(browser): Add processSpan hook for fetch timestamp adjustment Add a `processSpan` hook alongside the existing event processor to support the span streaming path for fetch stream timestamp corrections. Ref: https://github.com/getsentry/sentry-javascript/issues/20376 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/tracing/request.ts | 10 ++++++++++ packages/browser/test/tracing/request.test.ts | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 9cbf45563f0b..9b4859b61b20 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -176,6 +176,16 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { + if (span.attributes?.['sentry.op'] === 'http.client') { + const updatedTimestamp = spanIdToEndTimestamp.get(span.span_id); + if (updatedTimestamp) { + span.end_timestamp = updatedTimestamp / 1000; + spanIdToEndTimestamp.delete(span.span_id); + } + } + }); + if (trackFetchStreamPerformance) { addFetchEndInstrumentationHandler(handlerData => { if (handlerData.response) { diff --git a/packages/browser/test/tracing/request.test.ts b/packages/browser/test/tracing/request.test.ts index 1674a96d1937..a66bf1270f5d 100644 --- a/packages/browser/test/tracing/request.test.ts +++ b/packages/browser/test/tracing/request.test.ts @@ -13,9 +13,10 @@ beforeAll(() => { class MockClient implements Partial { public addEventProcessor: () => void; + public on: () => () => void; constructor() { - // Mock addEventProcessor function this.addEventProcessor = vi.fn(); + this.on = vi.fn(() => () => {}); } // @ts-expect-error not returning options for the test public getOptions() { From 745a2f33213a120bca2d24c79bde35b7f6366d2a Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 9 May 2026 10:32:32 +0200 Subject: [PATCH 2/3] feat(browser): Defer fetch span end for streamed responses Instead of ending the span at header arrival and patching timestamps later via event processor, defer the span end until the response body fully resolves. Removes the event processor entirely. Closes #20376 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/tracing/request.ts | 58 ++++++++----------- packages/browser/test/tracing/request.test.ts | 6 -- 2 files changed, 23 insertions(+), 41 deletions(-) diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 9b4859b61b20..831ddb7a8940 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -124,7 +124,7 @@ export interface RequestInstrumentationOptions { } const responseToSpanId = new WeakMap(); -const spanIdToEndTimestamp = new Map(); +const spanIdToDeferredSpan = new Map(); export const defaultRequestInstrumentationOptions: RequestInstrumentationOptions = { traceFetch: true, @@ -159,54 +159,42 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { - if (event.type === 'transaction' && event.spans) { - event.spans.forEach(span => { - if (span.op === 'http.client') { - const updatedTimestamp = spanIdToEndTimestamp.get(span.span_id); - if (updatedTimestamp) { - span.timestamp = updatedTimestamp / 1000; - spanIdToEndTimestamp.delete(span.span_id); - } - } - }); - } - return event; - }); - - client.on('processSpan', span => { - if (span.attributes?.['sentry.op'] === 'http.client') { - const updatedTimestamp = spanIdToEndTimestamp.get(span.span_id); - if (updatedTimestamp) { - span.end_timestamp = updatedTimestamp / 1000; - spanIdToEndTimestamp.delete(span.span_id); - } - } - }); - if (trackFetchStreamPerformance) { addFetchEndInstrumentationHandler(handlerData => { if (handlerData.response) { - const span = responseToSpanId.get(handlerData.response); - if (span && handlerData.endTimestamp) { - spanIdToEndTimestamp.set(span, handlerData.endTimestamp); + const spanId = responseToSpanId.get(handlerData.response); + if (spanId) { + const deferredSpan = spanIdToDeferredSpan.get(spanId); + if (deferredSpan && handlerData.endTimestamp) { + setHttpStatus(deferredSpan, handlerData.response.status); + deferredSpan.end(handlerData.endTimestamp); + spanIdToDeferredSpan.delete(spanId); + } } } }); } addFetchInstrumentationHandler(handlerData => { + // When tracking streaming performance, defer span end until the response body resolves. + // We intercept the end call, save the span, and let the fetchEndInstrumentationHandler + // end it with the correct timestamp. + if (trackFetchStreamPerformance && handlerData.endTimestamp && handlerData.response) { + const spanId = handlerData.fetchData?.__span; + if (spanId && spans[spanId]) { + responseToSpanId.set(handlerData.response, spanId); + spanIdToDeferredSpan.set(spanId, spans[spanId]); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete spans[spanId]; + return; + } + } + const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans, { propagateTraceparent, onRequestSpanEnd, }); - if (handlerData.response && handlerData.fetchData.__span) { - responseToSpanId.set(handlerData.response, handlerData.fetchData.__span); - } - // We cannot use `window.location` in the generic fetch instrumentation, // but we need it for reliable `server.address` attribute. // so we extend this in here diff --git a/packages/browser/test/tracing/request.test.ts b/packages/browser/test/tracing/request.test.ts index a66bf1270f5d..4bdd3886b275 100644 --- a/packages/browser/test/tracing/request.test.ts +++ b/packages/browser/test/tracing/request.test.ts @@ -12,12 +12,6 @@ beforeAll(() => { }); class MockClient implements Partial { - public addEventProcessor: () => void; - public on: () => () => void; - constructor() { - this.addEventProcessor = vi.fn(); - this.on = vi.fn(() => () => {}); - } // @ts-expect-error not returning options for the test public getOptions() { return {}; From 0d88922b8a5db8f3efea36a2eb66a160307351f2 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 9 May 2026 11:15:45 +0200 Subject: [PATCH 3/3] refactor(browser): Replay deferred spans through instrumentFetchRequest Instead of reimplementing endSpan logic (HTTP status, content-length, onRequestSpanEnd), stash the original handlerData, put the span back in the spans record, and let instrumentFetchRequest handle everything. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/tracing/request.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 831ddb7a8940..84d5b5c176f9 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ import type { Client, + HandlerDataFetch, HandlerDataXhr, RequestHookInfo, ResponseHookInfo, @@ -124,7 +125,7 @@ export interface RequestInstrumentationOptions { } const responseToSpanId = new WeakMap(); -const spanIdToDeferredSpan = new Map(); +const spanIdToDeferredData = new Map(); export const defaultRequestInstrumentationOptions: RequestInstrumentationOptions = { traceFetch: true, @@ -164,11 +165,15 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial