From e479578b6c23d4219036f270447855125f401d44 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 9 May 2026 10:25:36 +0200 Subject: [PATCH 1/7] 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/7] 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/7] 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 Date: Mon, 11 May 2026 12:38:56 +0200 Subject: [PATCH 4/7] test(browser): Add integration test for trackFetchStreamPerformance Add a Playwright test verifying that spans produced via the deferred span end path have correct attributes, status, and trace context. Also remove unnecessary dynamic delete from spans record. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../init.js | 14 ++++++ .../subject.js | 1 + .../test.ts | 45 +++++++++++++++++++ packages/browser/src/tracing/request.ts | 3 -- 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/init.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/init.js new file mode 100644 index 000000000000..e0a96e96ba67 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/init.js @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ trackFetchStreamPerformance: true }), + Sentry.spanStreamingIntegration(), + ], + tracePropagationTargets: ['http://sentry-test-site.example'], + tracesSampleRate: 1, + autoSessionTracking: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/subject.js b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/subject.js new file mode 100644 index 000000000000..0f8d062e5170 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/subject.js @@ -0,0 +1 @@ +fetch('http://sentry-test-site.example/delayed').then(res => res.text()); diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts new file mode 100644 index 000000000000..1f2a2bf70a68 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts @@ -0,0 +1,45 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpans } from '../../../../utils/spanUtils'; + +sentryTest( + 'span has correct attributes when trackFetchStreamPerformance is enabled', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + + await page.route('http://sentry-test-site.example/*', route => + route.fulfill({ body: 'ok', status: 200 }), + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spansPromise = waitForStreamedSpans(page, spans => spans.some(s => getSpanOp(s) === 'http.client')); + + await page.goto(url); + + const allSpans = await spansPromise; + const pageloadSpan = allSpans.find(s => getSpanOp(s) === 'pageload'); + const requestSpan = allSpans.find(s => getSpanOp(s) === 'http.client'); + + expect(requestSpan).toBeDefined(); + expect(requestSpan!.end_timestamp).toBeGreaterThan(requestSpan!.start_timestamp); + expect(requestSpan).toMatchObject({ + name: 'GET http://sentry-test-site.example/delayed', + parent_span_id: pageloadSpan?.span_id, + span_id: expect.stringMatching(/[a-f\d]{16}/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + trace_id: pageloadSpan?.trace_id, + status: 'ok', + attributes: expect.objectContaining({ + 'http.method': { type: 'string', value: 'GET' }, + 'http.url': { type: 'string', value: 'http://sentry-test-site.example/delayed' }, + url: { type: 'string', value: 'http://sentry-test-site.example/delayed' }, + 'server.address': { type: 'string', value: 'sentry-test-site.example' }, + type: { type: 'string', value: 'fetch' }, + 'http.response.status_code': { type: 'integer', value: 200 }, + }), + }); + }, +); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 84d5b5c176f9..9fbbded2c14d 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -167,7 +167,6 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial Date: Mon, 11 May 2026 12:44:32 +0200 Subject: [PATCH 5/7] refactor(browser): Clean up deferred span data structures Remove unused span field from deferred data map and rename for clarity. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/tracing/request.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 9fbbded2c14d..2df0f2d9c9d9 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -125,7 +125,7 @@ export interface RequestInstrumentationOptions { } const responseToSpanId = new WeakMap(); -const spanIdToDeferredData = new Map(); +const spanIdToDeferredHandlerData = new Map(); export const defaultRequestInstrumentationOptions: RequestInstrumentationOptions = { traceFetch: true, @@ -165,14 +165,14 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial Date: Mon, 11 May 2026 12:49:10 +0200 Subject: [PATCH 6/7] yarn fix --- .../request/fetch-streamed-track-stream-performance/test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts index 1f2a2bf70a68..cfa4ac46d19b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts @@ -8,9 +8,7 @@ sentryTest( async ({ getLocalTestUrl, page }) => { sentryTest.skip(shouldSkipTracingTest()); - await page.route('http://sentry-test-site.example/*', route => - route.fulfill({ body: 'ok', status: 200 }), - ); + await page.route('http://sentry-test-site.example/*', route => route.fulfill({ body: 'ok', status: 200 })); const url = await getLocalTestUrl({ testDir: __dirname }); From dfaaf2636ec3906fb1985317a925e26bc21da8aa Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 11 May 2026 13:45:37 +0200 Subject: [PATCH 7/7] update --- packages/browser/src/tracing/request.ts | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 2df0f2d9c9d9..33d8e7a94839 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -126,6 +126,10 @@ export interface RequestInstrumentationOptions { const responseToSpanId = new WeakMap(); const spanIdToDeferredHandlerData = new Map(); +const spanIdToFallbackTimeout = new Map>(); + +// Matches the max fetch timeout defined in core/src/instrument/fetch.ts +const STREAM_RESOLVE_FALLBACK_MS = 90_000; export const defaultRequestInstrumentationOptions: RequestInstrumentationOptions = { traceFetch: true, @@ -167,12 +171,20 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { + const deferredHandlerData = spanIdToDeferredHandlerData.get(spanId); + if (deferredHandlerData) { + instrumentFetchRequest(deferredHandlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans, { + propagateTraceparent, + onRequestSpanEnd, + }); + spanIdToDeferredHandlerData.delete(spanId); + spanIdToFallbackTimeout.delete(spanId); + } + }, STREAM_RESOLVE_FALLBACK_MS); + + spanIdToFallbackTimeout.set(spanId, fallbackTimeout); return; } }