diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 271556e7e61a..6afc276088b1 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(); } }); + + // `pagehide` is the last moment a document leaving the page can still send. We cannot rely on + // `registerBackgroundTabDetection`, which ends the span on `visibilitychange`: the client's own + // `visibilitychange` flush is registered first and its deferring microtask runs after that + // listener but before background tab detection's, so the buffer is still empty when it drains. + // The segment span is buffered right after with nothing left to flush it, and the children have + // been streamed all along, which leaves a rootless trace. Ending and flushing together here + // keeps this self-contained instead of depending on that ordering. + WINDOW.addEventListener?.('pagehide', () => { + const activeSpan = getActiveIdleSpan(client); + if (activeSpan && !spanToJSON(activeSpan).end_timestamp) { + activeSpan.setAttribute(SENTRY_IDLE_SPAN_FINISH_REASON, 'documentHidden'); + activeSpan.end(); + } + void client.flush(); + }); }, afterAllSetup(client) { @@ -594,7 +615,7 @@ export const browserTracingIntegration = ((options: Partial entry.name === name); return entry?.description; diff --git a/packages/browser/test/tracing/browserTracingIntegration.test.ts b/packages/browser/test/tracing/browserTracingIntegration.test.ts index a7cc1985cbd6..5f3d6d3858fa 100644 --- a/packages/browser/test/tracing/browserTracingIntegration.test.ts +++ b/packages/browser/test/tracing/browserTracingIntegration.test.ts @@ -770,6 +770,67 @@ 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', () => { + 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('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, + 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(