diff --git a/.size-limit.js b/.size-limit.js index 10efb849a582..324c5da003bc 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -120,7 +120,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '40.5 KB', + limit: '41 KB', }, // Vue SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/init.js index f26a4197747c..a3b99e2e1dc3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/init.js @@ -4,7 +4,8 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration()], + // We want to ignore redirects for this test + integrations: [Sentry.browserTracingIntegration({ detectRedirects: false })], tracesSampler: ctx => { if (ctx.attributes['sentry.origin'] === 'auto.pageload.browser') { return 0; diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js index 8fb188a75278..ad357eee8cc6 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js @@ -4,9 +4,12 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration()], + integrations: [Sentry.browserTracingIntegration({ idleTimeout: 2000 })], tracesSampleRate: 1, }); -// Immediately navigate to a new page to abort the pageload -window.history.pushState({}, '', '/sub-page'); +// Navigate to a new page to abort the pageload +// We have to wait >300ms to avoid the redirect handling +setTimeout(() => { + window.history.pushState({}, '', '/sub-page'); +}, 500); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click-early/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click-early/init.js new file mode 100644 index 000000000000..83abe7de1b7a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click-early/init.js @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1, + debug: true, +}); + +document.getElementById('btn1').addEventListener('click', () => { + // Trigger navigation later than click, so the last click is more than 300ms ago + setTimeout(() => { + window.history.pushState({}, '', '/sub-page'); + + // then trigger redirect inside of this navigation, which should be detected as a redirect + // because the last click was more than 300ms ago + setTimeout(() => { + window.history.pushState({}, '', '/sub-page-redirect'); + }, 100); + }, 400); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click-early/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click-early/template.html new file mode 100644 index 000000000000..d364e6680b41 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click-early/template.html @@ -0,0 +1,7 @@ + + +
+ + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click-early/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click-early/test.ts new file mode 100644 index 000000000000..97cbc67c8af8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click-early/test.ts @@ -0,0 +1,41 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest( + 'should create a navigation.redirect span if a click happened more than 300ms before navigation', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadRequestPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload'); + const navigationRequestPromise = waitForTransactionRequest( + page, + event => event.contexts?.trace?.op === 'navigation', + ); + + await page.goto(url); + + await pageloadRequestPromise; + + // Now trigger navigation, and then a redirect in the navigation, with + await page.click('#btn1'); + + const navigationRequest = envelopeRequestParser(await navigationRequestPromise); + + expect(navigationRequest.contexts?.trace?.op).toBe('navigation'); + expect(navigationRequest.transaction).toEqual('/sub-page'); + + const spans = navigationRequest.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'navigation.redirect', + description: '/sub-page-redirect', + }), + ); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click/init.js new file mode 100644 index 000000000000..0656ee398dcf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1, + debug: true, +}); + +document.getElementById('btn1').addEventListener('click', () => { + // trigger redirect immediately + window.history.pushState({}, '', '/sub-page'); +}); + +// Now trigger click, whic should trigger navigation +document.getElementById('btn1').click(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click/template.html new file mode 100644 index 000000000000..d364e6680b41 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click/template.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click/test.ts new file mode 100644 index 000000000000..4a5cb9acd73b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/click/test.ts @@ -0,0 +1,34 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest( + 'should not create a navigation.redirect span if a click happened before navigation', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadRequestPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload'); + const navigationRequestPromise = waitForTransactionRequest( + page, + event => event.contexts?.trace?.op === 'navigation', + ); + + await page.goto(url); + + const pageloadRequest = envelopeRequestParser(await pageloadRequestPromise); + // Ensure a navigation span is sent, too + await navigationRequestPromise; + + const spans = pageloadRequest.spans || []; + + expect(spans).not.toContainEqual( + expect.objectContaining({ + op: 'navigation.redirect', + }), + ); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/immediately/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/immediately/init.js new file mode 100644 index 000000000000..cba0015b22c8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/immediately/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1, +}); + +// trigger redirect immediately +window.history.pushState({}, '', '/sub-page'); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/immediately/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/immediately/test.ts new file mode 100644 index 000000000000..f2b3e885f6ce --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/immediately/test.ts @@ -0,0 +1,66 @@ +import { expect } from '@playwright/test'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest('should create a pageload transaction with navigation.redirect span', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadRequestPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload'); + + await page.goto(url); + + const pageloadRequest = envelopeRequestParser(await pageloadRequestPromise); + + expect(pageloadRequest.contexts?.trace?.op).toBe('pageload'); + + expect(pageloadRequest.contexts?.trace?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + ['sentry.idle_span_finish_reason']: 'idleTimeout', + }); + + expect(pageloadRequest.request).toEqual({ + headers: { + 'User-Agent': expect.any(String), + }, + url: 'http://sentry-test.io/index.html', + }); + + const spans = pageloadRequest.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'navigation.redirect', + }), + ); + + const navigationSpan = spans.find(span => span.op === 'navigation.redirect'); + expect(navigationSpan?.timestamp).toEqual(navigationSpan?.start_timestamp); + expect(navigationSpan).toEqual({ + data: { + 'sentry.op': 'navigation.redirect', + 'sentry.origin': 'auto.navigation.browser', + 'sentry.source': 'url', + }, + description: '/sub-page', + op: 'navigation.redirect', + origin: 'auto.navigation.browser', + parent_span_id: pageloadRequest.contexts!.trace!.span_id, + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/late/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/late/init.js new file mode 100644 index 000000000000..686f72903a89 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/late/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()], + tracesSampleRate: 1, +}); + +// trigger redirect later +setTimeout(() => { + window.history.pushState({}, '', '/sub-page'); +}, 400); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/late/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/late/test.ts new file mode 100644 index 000000000000..f1108cdbc1c5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/late/test.ts @@ -0,0 +1,34 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest( + 'should not create a navigation.redirect span if redirect happened more than 300ms after pageload', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadRequestPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload'); + const navigationRequestPromise = waitForTransactionRequest( + page, + event => event.contexts?.trace?.op === 'navigation', + ); + + await page.goto(url); + + const pageloadRequest = envelopeRequestParser(await pageloadRequestPromise); + // Ensure a navigation span is sent, too + await navigationRequestPromise; + + const spans = pageloadRequest.spans || []; + + expect(spans).not.toContainEqual( + expect.objectContaining({ + op: 'navigation.redirect', + }), + ); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/opt-out/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/opt-out/init.js new file mode 100644 index 000000000000..331024032a6f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/opt-out/init.js @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + detectRedirects: false, + }), + ], + tracesSampleRate: 1, +}); + +// trigger redirect immediately +window.history.pushState({}, '', '/sub-page'); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/opt-out/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/opt-out/test.ts new file mode 100644 index 000000000000..e96e9e650122 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-redirect/opt-out/test.ts @@ -0,0 +1,34 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; + +sentryTest( + 'should not create a navigation.redirect span if `detectRedirects` is set to false', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadRequestPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload'); + const navigationRequestPromise = waitForTransactionRequest( + page, + event => event.contexts?.trace?.op === 'navigation', + ); + + await page.goto(url); + + const pageloadRequest = envelopeRequestParser(await pageloadRequestPromise); + // Ensure a navigation span is sent, too + await navigationRequestPromise; + + const spans = pageloadRequest.spans || []; + + expect(spans).not.toContainEqual( + expect.objectContaining({ + op: 'navigation.redirect', + }), + ); + }, +); diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index d31fe41742f8..06b0c70eb18f 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -3,6 +3,7 @@ import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource, import { addNonEnumerableProperty, browserPerformanceTimeOrigin, + dateTimestampInSeconds, generateTraceId, getClient, getCurrentScope, @@ -20,6 +21,8 @@ import { spanIsSampled, spanToJSON, startIdleSpan, + startInactiveSpan, + timestampInSeconds, TRACING_DEFAULTS, } from '@sentry/core'; import { @@ -144,6 +147,14 @@ export interface BrowserTracingOptions { */ enableHTTPTimings: boolean; + /** + * By default, the SDK will try to detect redirects and avoid creating separate spans for them. + * If you want to opt-out of this behavior, you can set this option to `false`. + * + * Default: true + */ + detectRedirects: boolean; + /** * Link the currently started trace to a previous trace (e.g. a prior pageload, navigation or * manually started span). When enabled, this option will allow you to navigate between traces @@ -226,6 +237,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { enableLongTask: true, enableLongAnimationFrame: true, enableInp: true, + detectRedirects: true, linkPreviousTrace: 'in-memory', consistentTraceSampling: false, _experiments: {}, @@ -270,6 +282,7 @@ export const browserTracingIntegration = ((_options: Partial