From a5823c1220f3838585fee6a357c565b895dbea51 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 28 Aug 2026 13:57:55 -0400 Subject: [PATCH 1/3] fix(browser): End the active idle span on pagehide A navigation span whose idle timeout has not elapsed when the document goes away was reaching Sentry as a rootless trace: its children had been streamed while the page was alive, but the root span never arrived. `registerBackgroundTabDetection` is meant to be the safety net, but it listens for `visibilitychange`. Measured on a same-tab cross-document navigation in Chrome 152, the order is: pagehide (persisted=true) -> visibilitychange (hidden) `pagehide` comes first and freezes the document into the bfcache, so a root span ended on `visibilitychange` is ended on a page that can no longer send, and it is stranded on its own. That is also why the cancellation never appears in the debug log: it is logged from a frozen document. `pagehide` is the last point at which a document can still send, so the idle span is ended there instead, which also puts the root in the same final batch as the remaining children. The trace then arrives whole or not at all, rather than headless. Verified against a real project: rootless traces appear without this and consistently do not with it. Trace delivery volume is unchanged (7-8 of 10 traces over three runs each way), which is expected - this changes whether the root travels with its children, not how much gets through. The traces that go missing entirely are a separate problem: four envelope requests per run are killed with `net::ERR_ABORTED` during unload, which points at the `keepalive` budget in `fetch.ts` rather than at span lifecycle. --- .../src/tracing/browserTracingIntegration.ts | 12 ++++++ .../tracing/browserTracingIntegration.test.ts | 40 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 271556e7e61a..2ca4d7024a84 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -557,6 +557,18 @@ export const browserTracingIntegration = ((options: Partial { + const activeSpan = getActiveIdleSpan(client); + if (activeSpan && !spanToJSON(activeSpan).end_timestamp) { + activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'documentHidden'); + activeSpan.end(); + } + }); }, afterAllSetup(client) { diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index a7cc1985cbd6..bc3b9c9c6cf7 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -770,6 +770,46 @@ describe('browserTracingIntegration', () => { expect(spanToJSON(pageloadSpan!).attributes[SENTRY_SEGMENT_NAME_SOURCE]).toBe('custom'); }); + describe('pagehide', () => { + it('ends the active idle span so its root is not stranded on a frozen page', () => { + // `registerBackgroundTabDetection` waits for `visibilitychange`, which on a same-tab + // navigation fires after `pagehide` has already frozen the document into the bfcache. A root + // ended there can never be sent, while its children have been streaming all along. + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration()], + }), + ); + setCurrentClient(client); + client.init(); + + const span = getActiveSpan()!; + expect(span).toBeDefined(); + expect(spanToJSON(span).end_timestamp).toBeUndefined(); + + WINDOW.dispatchEvent(new Event('pagehide')); + + const json = spanToJSON(span); + expect(json.end_timestamp).toBeDefined(); + expect(json.attributes?.['sentry.idle_span_finish_reason']).toBe('documentHidden'); + }); + + it('does nothing when there is no active idle span', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration({ instrumentPageLoad: false })], + }), + ); + setCurrentClient(client); + client.init(); + + expect(() => WINDOW.dispatchEvent(new Event('pagehide'))).not.toThrow(); + expect(getActiveSpan()).toBeUndefined(); + }); + }); + describe('startBrowserTracingNavigationSpan', () => { it('works without integration setup', () => { const client = new BrowserClient( From 1867e38ee10d8003179607054f78d14318233ac9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 8 Sep 2026 11:04:28 -0400 Subject: [PATCH 2/3] ref: use attrs from semantic conventions --- .../src/tracing/browserTracingIntegration.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 2ca4d7024a84..1be0c774e0f8 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -19,8 +19,6 @@ import { parseStringToURLObject, propagationContextFromHeaders, registerSpanErrorInstrumentation, - SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanIsSampled, spanToJSON, timestampInSeconds, @@ -43,7 +41,14 @@ import { WEB_VITALS_INTEGRATION_NAME, webVitalsIntegration } from '../integratio import { registerBackgroundTabDetection } from './backgroundtab'; import { linkTraces } from './linkedTraces'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request'; -import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import { + SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_OP, + URL_FULL, + URL_PATH, + SENTRY_ORIGIN, + SENTRY_IDLE_SPAN_FINISH_REASON, +} from '@sentry/conventions/attributes'; import { NAVIGATION, NAVIGATION_REDIRECT, PAGELOAD } from '@sentry/conventions/op'; export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; @@ -427,7 +432,7 @@ export const browserTracingIntegration = ((options: Partial { if (enableReportPageLoaded && _pageloadSpan) { - _pageloadSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'reportPageLoaded'); + _pageloadSpan.setAttribute(SENTRY_IDLE_SPAN_FINISH_REASON, 'reportPageLoaded'); _pageloadSpan.end(); } }); @@ -565,7 +570,7 @@ export const browserTracingIntegration = ((options: Partial { const activeSpan = getActiveIdleSpan(client); if (activeSpan && !spanToJSON(activeSpan).end_timestamp) { - activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'documentHidden'); + activeSpan.setAttribute(SENTRY_IDLE_SPAN_FINISH_REASON, 'documentHidden'); activeSpan.end(); } }); @@ -606,7 +611,7 @@ export const browserTracingIntegration = ((options: Partial entry.name === name); return entry?.description; From 310e74eb35f5a4a143fc3a00c97d3eec55c3fb0a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 8 Sep 2026 11:47:38 -0400 Subject: [PATCH 3/3] fix(browser): Flush after ending the idle span on pagehide The client's `visibilitychange` flush defers via `queueMicrotask` so that background tab detection ends the segment span first. For a browser-fired event the microtask checkpoint runs after each listener callback rather than after all of them, so the flush drains an empty buffer and the span is buffered right after with nothing left to send it. Ending and flushing together in the `pagehide` handler keeps this self-contained instead of depending on that ordering. --- .../src/tracing/browserTracingIntegration.ts | 12 +++++--- .../tracing/browserTracingIntegration.test.ts | 29 ++++++++++++++++--- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 1be0c774e0f8..6afc276088b1 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -563,16 +563,20 @@ export const browserTracingIntegration = ((options: Partial { const activeSpan = getActiveIdleSpan(client); if (activeSpan && !spanToJSON(activeSpan).end_timestamp) { activeSpan.setAttribute(SENTRY_IDLE_SPAN_FINISH_REASON, 'documentHidden'); activeSpan.end(); } + void client.flush(); }); }, diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index bc3b9c9c6cf7..5f3d6d3858fa 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -772,9 +772,6 @@ describe('browserTracingIntegration', () => { describe('pagehide', () => { it('ends the active idle span so its root is not stranded on a frozen page', () => { - // `registerBackgroundTabDetection` waits for `visibilitychange`, which on a same-tab - // navigation fires after `pagehide` has already frozen the document into the bfcache. A root - // ended there can never be sent, while its children have been streaming all along. const client = new BrowserClient( getDefaultBrowserClientOptions({ tracesSampleRate: 1, @@ -795,7 +792,31 @@ describe('browserTracingIntegration', () => { expect(json.attributes?.['sentry.idle_span_finish_reason']).toBe('documentHidden'); }); - it('does nothing when there is no active idle span', () => { + it('flushes after ending the span, so the segment span is in the buffer when it drains', () => { + const client = new BrowserClient( + getDefaultBrowserClientOptions({ + tracesSampleRate: 1, + integrations: [browserTracingIntegration()], + }), + ); + setCurrentClient(client); + client.init(); + + const span = getActiveSpan()!; + + let endTimestampWhenFlushed: number | undefined; + const flushSpy = vi.spyOn(client, 'flush').mockImplementation(() => { + endTimestampWhenFlushed = spanToJSON(span).end_timestamp; + return Promise.resolve(true); + }); + + WINDOW.dispatchEvent(new Event('pagehide')); + + expect(flushSpy).toHaveBeenCalled(); + expect(endTimestampWhenFlushed).toBeDefined(); + }); + + it('ends no span when there is no active idle span', () => { const client = new BrowserClient( getDefaultBrowserClientOptions({ tracesSampleRate: 1,