From 53b3d4532566715a08b988a5c71dc6b2a77882de Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 28 Aug 2026 13:57:55 -0400 Subject: [PATCH] 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 49e6abf319c5..03c400598688 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -566,6 +566,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 5ef5172f16d4..a41ceb7bcc8c 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(