diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx index f4fb98246390..cc9b307e620a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx @@ -11,7 +11,6 @@ const replay = Sentry.replayIntegration(); const history = createBrowserHistory(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN || diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts index 96b4a042c08b..17ca866ea821 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends correct error event', async ({ page }) => { const errorEventPromise = waitForError('react-router-5', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-5', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-5', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-5', event => { @@ -39,9 +39,9 @@ test('Sets correct transactionName', async ({ page }) => { }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - // Only capture error once transaction was sent + // Only capture error once the pageload span was sent const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); @@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => { expect(errorEvent.transaction).toEqual('/'); expect(errorEvent.contexts?.trace).toEqual({ - trace_id: transactionEvent.contexts?.trace?.trace_id, - span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-5/tests/spans.test.ts new file mode 100644 index 000000000000..d9ee28ee6689 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/tests/spans.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-5', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/`); + + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v5', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + page.on('console', msg => console.log(msg.text())); + const pageloadSpanPromise = waitForStreamedSpan('react-router-5', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-5', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v5', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts deleted file mode 100644 index 7e053e5c9fb2..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-5', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/`); - - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v5', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - page.on('console', msg => console.log(msg.text())); - const pageloadTxnPromise = waitForTransaction('react-router-5', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-5', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - await pageloadTxnPromise; - - const linkElement = page.locator('id=navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v5', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }, - }, - }, - transaction: '/user/:id', - transaction_info: { - source: 'route', - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx index aca20a716203..58fadb60f61c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx @@ -16,7 +16,6 @@ import Index from './pages/Index'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/spans.test.ts new file mode 100644 index 000000000000..2a50331fa451 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/spans.test.ts @@ -0,0 +1,294 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/projects/123/views/234/567`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(span.name).toBe('/projects/:projectId/views/:viewId/:detailId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/views/234/567', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/234\/567$/), + type: 'string', + }, + }); +}); + +test('sends a pageload span with a parameterized URL - alternative route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/projects/234/old-views/234/567`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(span.name).toBe('/projects/:projectId/old-views/:viewId/:detailId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/old-views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/234/old-views/234/567', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/234\/old-views\/234\/567$/), + type: 'string', + }, + }); +}); + +test('keeps the parent path prefix for a descendant route with non-wildcard nested children - pageload', async ({ + page, +}) => { + const spanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/child/abc123`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Child')).toBe(true); + expect(span.name).toBe('/child/:id'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/child/:id', type: 'string' }, + 'url.path': { value: '/child/abc123', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(navigationSpan.name).toBe('/projects/:projectId/views/:viewId/:detailId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/views/456/789', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/456\/789$/), + type: 'string', + }, + }); +}); + +test('sends a navigation span with a parameterized URL - alternative route', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=old-navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(navigationSpan.name).toBe('/projects/:projectId/old-views/:viewId/:detailId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/old-views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/old-views/345/654', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/old-views\/345\/654$/), + type: 'string', + }, + }); +}); + +test('keeps the parent path prefix for a descendant route with non-wildcard nested children - navigation', async ({ + page, +}) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=child-navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Child')).toBe(true); + expect(navigationSpan.name).toBe('/child/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/child/:id', type: 'string' }, + 'url.path': { value: '/child/abc123', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), type: 'string' }, + }); +}); + +test('resolves deep wildcard chain with three levels of nesting - pageload', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/workspace/team/u123`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); + expect(span.name).toBe('/workspace/:teamId/:memberId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/workspace/:teamId/:memberId', type: 'string' }, + 'url.path': { value: '/workspace/team/u123', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), type: 'string' }, + }); +}); + +test('does not mix param names across independent descendant routers', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const fooNavigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.attributes['url.path']?.value === '/foo/123'; + }); + + const barNavigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.attributes['url.path']?.value === '/bar/456'; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + // Mount the first descendant router (`foo/*` -> `:fooId`), which populates the shared `allRoutes` set. + const [, fooNavigationSpan] = await Promise.all([ + page.locator('id=foo-navigation').click(), + fooNavigationSpanPromise, + ]); + + expect((await page.innerHTML('#root')).includes('Foo')).toBe(true); + expect(fooNavigationSpan.name).toBe('/foo/:fooId'); + expect(fooNavigationSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); + + // Return to the index so we can navigate into the second, unrelated descendant router client-side. + // A fresh page load would reset the module-level `allRoutes` and hide the bug. + await page.goBack(); + await page.locator('id=bar-navigation').waitFor(); + + // Now mount the second descendant router (`bar/*` -> `:barId`). With the accumulation bug, the name + // comes out as the hybrid `/bar/:fooId`. + const [, barNavigationSpan] = await Promise.all([ + page.locator('id=bar-navigation').click(), + barNavigationSpanPromise, + ]); + + expect((await page.innerHTML('#root')).includes('Bar')).toBe(true); + expect(barNavigationSpan.name).toBe('/bar/:barId'); + expect(barNavigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/bar/:barId', type: 'string' }, + 'url.path': { value: '/bar/456', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/bar\/456$/), type: 'string' }, + }); +}); + +test('resolves deep wildcard chain with three levels of nesting - navigation', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=deep-member-navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); + expect(navigationSpan.name).toBe('/workspace/:teamId/:memberId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/workspace/:teamId/:memberId', type: 'string' }, + 'url.path': { value: '/workspace/team/u123', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts deleted file mode 100644 index 61ee41d33301..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/projects/123/views/234/567`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/views/:viewId/:detailId', - 'url.path': '/projects/123/views/234/567', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/234\/567$/), - }, - }, - }, - transaction: '/projects/:projectId/views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a pageload transaction with a parameterized URL - alternative route', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/projects/234/old-views/234/567`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/old-views/:viewId/:detailId', - 'url.path': '/projects/234/old-views/234/567', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/234\/old-views\/234\/567$/), - }, - }, - }, - transaction: '/projects/:projectId/old-views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('keeps the parent path prefix for a descendant route with non-wildcard nested children - pageload', async ({ - page, -}) => { - const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/child/abc123`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Child')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/child/:id', - 'url.path': '/child/abc123', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), - }, - }, - }, - transaction: '/child/:id', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/views/:viewId/:detailId', - 'url.path': '/projects/123/views/456/789', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/456\/789$/), - }, - }, - }, - transaction: '/projects/:projectId/views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL - alternative route', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=old-navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/old-views/:viewId/:detailId', - 'url.path': '/projects/123/old-views/345/654', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/old-views\/345\/654$/), - }, - }, - }, - transaction: '/projects/:projectId/old-views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('keeps the parent path prefix for a descendant route with non-wildcard nested children - navigation', async ({ - page, -}) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=child-navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Child')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/child/:id', - 'url.path': '/child/abc123', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), - }, - }, - }, - transaction: '/child/:id', - transaction_info: { - source: 'route', - }, - }); -}); - -test('resolves deep wildcard chain with three levels of nesting - pageload', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/workspace/team/u123`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/workspace/:teamId/:memberId', - 'url.path': '/workspace/team/u123', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), - }, - }, - }, - transaction: '/workspace/:teamId/:memberId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('does not mix param names across independent descendant routers', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const fooNavigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.contexts?.trace?.data?.['url.path'] === '/foo/123' - ); - }); - - const barNavigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.contexts?.trace?.data?.['url.path'] === '/bar/456' - ); - }); - - await page.goto(`/`); - await pageloadTxnPromise; - - // Mount the first descendant router (`foo/*` -> `:fooId`), which populates the shared `allRoutes` set. - const [, fooNavigationTxn] = await Promise.all([page.locator('id=foo-navigation').click(), fooNavigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Foo')).toBe(true); - expect(fooNavigationTxn).toMatchObject({ - transaction: '/foo/:fooId', - transaction_info: { source: 'route' }, - }); - - // Return to the index so we can navigate into the second, unrelated descendant router client-side. - // A fresh page load would reset the module-level `allRoutes` and hide the bug. - await page.goBack(); - await page.locator('id=bar-navigation').waitFor(); - - // Now mount the second descendant router (`bar/*` -> `:barId`). With the accumulation bug, the name - // comes out as the hybrid `/bar/:fooId`. - const [, barNavigationTxn] = await Promise.all([page.locator('id=bar-navigation').click(), barNavigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Bar')).toBe(true); - expect(barNavigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/bar/:barId', - 'url.path': '/bar/456', - }, - }, - }, - transaction: '/bar/:barId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('resolves deep wildcard chain with three levels of nesting - navigation', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - await pageloadTxnPromise; - - const linkElement = page.locator('id=deep-member-navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/workspace/:teamId/:memberId', - 'url.path': '/workspace/team/u123', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), - }, - }, - }, - transaction: '/workspace/:teamId/:memberId', - transaction_info: { - source: 'route', - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx index a049e56eee74..0ac33b9f6c5f 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx @@ -15,7 +15,6 @@ import User from './pages/User'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/errors.test.ts index 272025d95877..707a6af5df7a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends correct error event', async ({ page }) => { const errorEventPromise = waitForError('react-router-6-use-routes', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-use-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-use-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-6-use-routes', event => { @@ -39,9 +39,9 @@ test('Sets correct transactionName', async ({ page }) => { }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - // Only capture error once transaction was sent + // Only capture error once the pageload span was sent const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); @@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => { expect(errorEvent.transaction).toEqual('/'); expect(errorEvent.contexts?.trace).toEqual({ - trace_id: transactionEvent.contexts?.trace?.trace_id, - span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/spans.test.ts new file mode 100644 index 000000000000..8fe66736d026 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/spans.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-use-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/`); + + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + page.on('console', msg => console.log(msg.text())); + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-use-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-use-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts deleted file mode 100644 index 4c5e65b25e4e..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-use-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/`); - - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - page.on('console', msg => console.log(msg.text())); - const pageloadTxnPromise = waitForTransaction('react-router-6-use-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-use-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - await pageloadTxnPromise; - - const linkElement = page.locator('id=navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }, - }, - }, - transaction: '/user/:id', - transaction_info: { - source: 'route', - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx index b569aa9aedd1..58dc4614f650 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx @@ -18,7 +18,6 @@ import User from './pages/User'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/errors.test.ts index e34699420ccf..cbb90ddac056 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends correct error event', async ({ page }) => { const errorEventPromise = waitForError('react-router-6', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-6', event => { @@ -39,9 +39,9 @@ test('Sets correct transactionName', async ({ page }) => { }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - // Only capture error once transaction was sent + // Only capture error once the pageload span was sent const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); @@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => { expect(errorEvent.transaction).toEqual('/'); expect(errorEvent.contexts?.trace).toEqual({ - trace_id: transactionEvent.contexts?.trace?.trace_id, - span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/navigation-trace-propagation.test.ts index 477b27fd40f5..53a6cbc2fd6d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/navigation-trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/navigation-trace-propagation.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({ page, @@ -15,22 +15,22 @@ test('propagates the navigation trace (not the stale pageload trace) for a fetch }); }); - const pageloadTxnPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-6', async transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/products'; + const navigationSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/products'; }); await page.goto('/'); - const pageloadTxn = await pageloadTxnPromise; + const pageloadSpan = await pageloadSpanPromise; await page.locator('id=navigation-products').click(); - const navigationTxn = await navigationTxnPromise; + const navigationSpan = await navigationSpanPromise; - const pageloadTraceId = pageloadTxn.contexts?.trace?.trace_id; - const navigationTraceId = navigationTxn.contexts?.trace?.trace_id; + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; const propagatedTraceId = productsRequestSentryTrace?.split('-')[0]; expect(pageloadTraceId).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts similarity index 57% rename from dev-packages/e2e-tests/test-applications/react-router-6/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts index 22f79766c400..d4303fc9ab72 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts @@ -1,69 +1,51 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/`); - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, }); }); -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { +test('sends a navigation span with a parameterized URL', async ({ page }) => { page.on('console', msg => console.log(msg.text())); - const pageloadTxnPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + const navigationSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; }); await page.goto(`/`); - await pageloadTxnPromise; + await pageloadSpanPromise; const linkElement = page.locator('id=navigation'); - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }, - }, - }, - transaction: '/user/:id', - transaction_info: { - source: 'route', - }, + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts index a73642b20b6f..6b859c9e789c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts @@ -1,107 +1,121 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +// Under span streaming the http.client name is only ` `, so the request URL has to +// come from the `url.full` attribute. +function findHttpClientSpan(spans: SerializedStreamedSpan[], op: string, urlFull: string): SerializedStreamedSpan { + return spans.find(span => getSpanOp(span) === op && span.attributes['url.full']?.value === urlFull)!; +} + +function durationInSeconds(span: SerializedStreamedSpan): number { + return Math.round(span.end_timestamp - span.start_timestamp); +} + +/** + * Every test here loads `/sse` and produces identically named spans, and streamed spans are buffered + * before they flush, so an earlier test's page load can still be arriving. A pageload span starts at + * the browser's time origin, so requiring it to start after this test navigated is what picks out + * this test's own trace. + */ +function isPageloadSpanAfter(navigatedAt: number) { + return (span: SerializedStreamedSpan) => + getSpanOp(span) === 'pageload' && span.is_segment && span.start_timestamp >= navigatedAt; +} test('Waits for sse streaming when creating spans', async ({ page }) => { + const navigatedAt = Date.now() / 1000; await page.goto('/sse'); - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); + const spansPromise = collectStreamedSpans( + 'react-router-6', + spansOfTrace => + spansOfTrace.some(isPageloadSpanAfter(navigatedAt)) && + spansOfTrace.some(span => getSpanOp(span) === 'http.client.stream'), + ); const fetchButton = page.locator('id=fetch-button'); await fetchButton.click(); - const rootSpan = await transactionPromise; - const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0]!; - const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse')[0]!; - const httpStream = rootSpan.spans?.filter(span => span.op === 'http.client.stream')[0]!; + const spans = await spansPromise; + + const sseFetchCall = spans.find(span => span.name === 'sse fetch call')!; + const httpGet = findHttpClientSpan(spans, 'http.client', 'http://localhost:8080/sse'); + const httpStream = findHttpClientSpan(spans, 'http.client.stream', 'http://localhost:8080/sse'); expect(sseFetchCall).toBeDefined(); expect(httpGet).toBeDefined(); expect(httpStream).toBeDefined(); // http headers get sent instantly from the server - const resolveDuration = Math.round((sseFetchCall.timestamp as number) - sseFetchCall.start_timestamp); - - // http.client span ends at header arrival (~0s) - const httpGetDuration = Math.round((httpGet.timestamp as number) - httpGet.start_timestamp); - - // body streaming duration is captured in the sibling http.client.stream span (~2s) - const streamDuration = Math.round((httpStream.timestamp as number) - httpStream.start_timestamp); - - expect(resolveDuration).toBe(0); - expect(httpGetDuration).toBe(0); - expect(streamDuration).toBe(2); + // http.client span ends at header arrival (~0s), body streaming duration is captured in the + // sibling http.client.stream span (~2s) + expect(durationInSeconds(sseFetchCall)).toBe(0); + expect(durationInSeconds(httpGet)).toBe(0); + expect(durationInSeconds(httpStream)).toBe(2); }); test('Waits for sse streaming when sse has been explicitly aborted', async ({ page }) => { + const navigatedAt = Date.now() / 1000; await page.goto('/sse'); - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); + const consoleMessages: string[] = []; + page.on('console', msg => consoleMessages.push(msg.text())); + + const spansPromise = collectStreamedSpans( + 'react-router-6', + spansOfTrace => + spansOfTrace.some(isPageloadSpanAfter(navigatedAt)) && spansOfTrace.some(span => span.name === 'sse fetch call'), + ); const fetchButton = page.locator('id=fetch-sse-abort'); await fetchButton.click(); - const rootSpan = await transactionPromise; - const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0]!; - const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse')[0]!; + const spans = await spansPromise; + + const sseFetchCall = spans.find(span => span.name === 'sse fetch call')!; + const httpGet = findHttpClientSpan(spans, 'http.client', 'http://localhost:8080/sse'); expect(sseFetchCall).toBeDefined(); expect(httpGet).toBeDefined(); - expect(sseFetchCall?.timestamp).toBeDefined(); - expect(sseFetchCall?.start_timestamp).toBeDefined(); - expect(httpGet?.timestamp).toBeDefined(); - expect(httpGet?.start_timestamp).toBeDefined(); - - // http headers get sent instantly from the server - const resolveDuration = Math.round((sseFetchCall.timestamp as number) - sseFetchCall.start_timestamp); - - // body streams after 0s because it has been aborted - const resolveBodyDuration = Math.round((httpGet.timestamp as number) - httpGet.start_timestamp); - - expect(resolveDuration).toBe(0); - expect(resolveBodyDuration).toBe(0); - - // validate abort error was thrown by inspecting console - expect(rootSpan.breadcrumbs).toContainEqual( - expect.objectContaining({ - category: 'console', - message: 'Could not fetch sse AbortError: BodyStreamBuffer was aborted', - }), - ); + // http headers get sent instantly from the server, and the body streams after 0s because it has + // been aborted + expect(durationInSeconds(sseFetchCall)).toBe(0); + expect(durationInSeconds(httpGet)).toBe(0); + + // Spans carry no breadcrumbs, so the abort error is validated on the console directly + expect( + consoleMessages.some( + message => message.includes('Could not fetch sse') && message.includes('BodyStreamBuffer was aborted'), + ), + ).toBe(true); }); test('Aborts when stream takes longer than 5s, by not updating the span duration', async ({ page }) => { + const navigatedAt = Date.now() / 1000; await page.goto('/sse'); - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); + const spansPromise = collectStreamedSpans( + 'react-router-6', + spansOfTrace => + spansOfTrace.some(isPageloadSpanAfter(navigatedAt)) && + spansOfTrace.some(span => span.attributes['url.full']?.value === 'http://localhost:8080/sse-timeout'), + ); const fetchButton = page.locator('id=fetch-timeout-button'); await fetchButton.click(); - const rootSpan = await transactionPromise; - const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0]!; - const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse-timeout')[0]!; + const spans = await spansPromise; + + const sseFetchCall = spans.find(span => span.name === 'sse fetch call')!; + const httpGet = findHttpClientSpan(spans, 'http.client', 'http://localhost:8080/sse-timeout'); expect(sseFetchCall).toBeDefined(); expect(httpGet).toBeDefined(); - expect(sseFetchCall?.timestamp).toBeDefined(); - expect(sseFetchCall?.start_timestamp).toBeDefined(); - expect(httpGet?.timestamp).toBeDefined(); - expect(httpGet?.start_timestamp).toBeDefined(); - - // http headers get sent instantly from the server - const resolveDuration = Math.round((sseFetchCall.timestamp as number) - sseFetchCall.start_timestamp); - - // body streams after 10s but client should abort reading after 5s - const resolveBodyDuration = Math.round((httpGet.timestamp as number) - httpGet.start_timestamp); - - expect(resolveDuration).toBe(0); - expect(resolveBodyDuration).toBe(0); + // http headers get sent instantly from the server, and the body streams after 10s but the client + // aborts reading after 5s + expect(durationInSeconds(sseFetchCall)).toBe(0); + expect(durationInSeconds(httpGet)).toBe(0); });