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); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/.gitignore new file mode 100644 index 000000000000..ebb991370034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/.gitignore @@ -0,0 +1,32 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts + +# react router +.react-router diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/app.css new file mode 100644 index 000000000000..b31c3a9d0ddf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/app.css @@ -0,0 +1,6 @@ +html, +body { + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/context.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/context.ts new file mode 100644 index 000000000000..a15189e5bed8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react-router'; + +export type User = { + id: string; + name: string; +}; + +export const userContext = createContext(null); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.client.tsx new file mode 100644 index 000000000000..d4a0e1d0900b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.client.tsx @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/react-router'; +import { StrictMode, startTransition } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { HydratedRouter } from 'react-router/dom'; + +Sentry.init({ + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + // todo: get this from env + dsn: 'https://username@domain/123', + tunnel: `http://localhost:3031/`, // proxy server + integrations: [Sentry.reactRouterTracingIntegration()], + tracesSampleRate: 1.0, + tracePropagationTargets: [/^\//], +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.server.tsx new file mode 100644 index 000000000000..738cd1515a4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.server.tsx @@ -0,0 +1,18 @@ +import { createReadableStreamFromReadable } from '@react-router/node'; +import * as Sentry from '@sentry/react-router'; +import { renderToPipeableStream } from 'react-dom/server'; +import { ServerRouter } from 'react-router'; +import { type HandleErrorFunction } from 'react-router'; + +const ABORT_DELAY = 5_000; + +const handleRequest = Sentry.createSentryHandleRequest({ + streamTimeout: ABORT_DELAY, + ServerRouter, + renderToPipeableStream, + createReadableStreamFromReadable, +}); + +export default handleRequest; + +export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/root.tsx new file mode 100644 index 000000000000..bc1b8f1236c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/root.tsx @@ -0,0 +1,67 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router'; +import type { Route } from './+types/root'; +import stylesheet from './app.css?url'; + +export const links: Route.LinksFunction = () => [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', + }, + { rel: 'stylesheet', href: stylesheet }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!'; + let details = 'An unexpected error occurred.'; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error'; + details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; + } else if (error && error instanceof Error) { + if (import.meta.env.DEV) { + details = error.message; + stack = error.stack; + } + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes.ts new file mode 100644 index 000000000000..3e3839295b1d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes.ts @@ -0,0 +1,24 @@ +import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes'; + +export default [ + index('routes/home.tsx'), + route('__sentry-flush', 'routes/sentry-flush.tsx'), + ...prefix('errors', [ + route('client', 'routes/errors/client.tsx'), + route('client/:client-param', 'routes/errors/client-param.tsx'), + route('client-loader', 'routes/errors/client-loader.tsx'), + route('server-loader', 'routes/errors/server-loader.tsx'), + route('client-action', 'routes/errors/client-action.tsx'), + route('server-action', 'routes/errors/server-action.tsx'), + ]), + ...prefix('performance', [ + index('routes/performance/index.tsx'), + route('ssr', 'routes/performance/ssr.tsx'), + route('with/:param', 'routes/performance/dynamic-param.tsx'), + route('static', 'routes/performance/static.tsx'), + route('server-loader', 'routes/performance/server-loader.tsx'), + route('server-action', 'routes/performance/server-action.tsx'), + route('with-middleware', 'routes/performance/with-middleware.tsx'), + route('redis', 'routes/performance/redis.tsx'), + ]), +] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-action.tsx new file mode 100644 index 000000000000..d3b2d08eef2e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-action.tsx @@ -0,0 +1,18 @@ +import { Form } from 'react-router'; + +export function clientAction() { + throw new Error('Madonna mia! Che casino nella Client Action!'); +} + +export default function ClientActionErrorPage() { + return ( +
+

Client Error Action Page

+
+ +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-loader.tsx new file mode 100644 index 000000000000..72d9e62a99dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export function clientLoader() { + throw new Error('¡Madre mía del client loader!'); + return { data: 'sad' }; +} + +export default function ClientLoaderErrorPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Client Loader Error Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-param.tsx new file mode 100644 index 000000000000..a2e423391f03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-param.tsx @@ -0,0 +1,17 @@ +import type { Route } from './+types/client-param'; + +export default function ClientErrorParamPage({ params }: Route.ComponentProps) { + return ( +
+

Client Error Param Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client.tsx new file mode 100644 index 000000000000..190074a5ef09 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client.tsx @@ -0,0 +1,15 @@ +export default function ClientErrorPage() { + return ( +
+

Client Error Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-action.tsx new file mode 100644 index 000000000000..863c320f3557 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-action.tsx @@ -0,0 +1,18 @@ +import { Form } from 'react-router'; + +export function action() { + throw new Error('Madonna mia! Che casino nella Server Action!'); +} + +export default function ServerActionErrorPage() { + return ( +
+

Server Error Action Page

+
+ +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-loader.tsx new file mode 100644 index 000000000000..cb777686d540 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export function loader() { + throw new Error('¡Madre mía del server!'); + return { data: 'sad' }; +} + +export default function ServerLoaderErrorPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Server Error Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/home.tsx new file mode 100644 index 000000000000..4498e7a0d017 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/home.tsx @@ -0,0 +1,9 @@ +import type { Route } from './+types/home'; + +export function meta({}: Route.MetaArgs) { + return [{ title: 'New React Router App' }, { name: 'description', content: 'Welcome to React Router!' }]; +} + +export default function Home() { + return
home
; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/dynamic-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/dynamic-param.tsx new file mode 100644 index 000000000000..1ac02775f2ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/dynamic-param.tsx @@ -0,0 +1,17 @@ +import type { Route } from './+types/dynamic-param'; + +export async function loader() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { data: 'burritos' }; +} + +export default function DynamicParamPage({ params }: Route.ComponentProps) { + const { param } = params; + + return ( +
+

Dynamic Parameter Page

+

The parameter value is: {param}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/index.tsx new file mode 100644 index 000000000000..4c086645603e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/index.tsx @@ -0,0 +1,17 @@ +import { Link } from 'react-router'; + +export default function PerformancePage() { + return ( +
+

Performance Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/redis.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/redis.tsx new file mode 100644 index 000000000000..cba8275fcf63 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/redis.tsx @@ -0,0 +1,22 @@ +import Redis from 'ioredis'; +import type { Route } from './+types/redis'; + +const redis = new Redis(); + +export async function loader() { + const key = 'cache:greeting'; + await redis.set(key, 'hello from react-router'); + const value = await redis.get(key); + + return { value }; +} + +export default function RedisPage({ loaderData }: Route.ComponentProps) { + const { value } = loaderData; + return ( +
+

Redis Page

+
{value}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-action.tsx new file mode 100644 index 000000000000..462fc6fbf54c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-action.tsx @@ -0,0 +1,24 @@ +import { Form } from 'react-router'; +import type { Route } from './+types/server-action'; + +export async function action({ request }: Route.ActionArgs) { + let formData = await request.formData(); + let name = formData.get('name'); + await new Promise(resolve => setTimeout(resolve, 1000)); + return { + greeting: `Hola ${name}`, + }; +} + +export default function Project({ actionData }: Route.ComponentProps) { + return ( +
+

Server action page

+
+ + +
+ {actionData ?

{actionData.greeting}

: null} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-loader.tsx new file mode 100644 index 000000000000..e5c222ff4c05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export async function loader() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { data: 'burritos' }; +} + +export default function ServerLoaderPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Server Loader Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/ssr.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/ssr.tsx new file mode 100644 index 000000000000..8226e68f3be0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/ssr.tsx @@ -0,0 +1,14 @@ +import { useNavigate } from 'react-router'; + +export default function SsrPage() { + const navigate = useNavigate(); + + return ( +
+

SSR Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/static.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/static.tsx new file mode 100644 index 000000000000..3dea24381fdc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/static.tsx @@ -0,0 +1,3 @@ +export default function StaticPage() { + return

Static Page

; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/with-middleware.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/with-middleware.tsx new file mode 100644 index 000000000000..c86f78e17164 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/with-middleware.tsx @@ -0,0 +1,38 @@ +import type { Route } from './+types/with-middleware'; +import type { User } from '../../context'; +import { userContext } from '../../context'; +import * as Sentry from '@sentry/react-router'; + +async function getUser() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { + id: '1', + name: 'Carlos Gomez', + }; +} + +const authMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => { + Sentry.startSpan({ name: 'authMiddleware', op: 'middleware.auth' }, async () => { + const user: User = await getUser(); + context.set(userContext, user); + await next(); + }); +}; + +export const middleware: Route.MiddlewareFunction[] = [authMiddleware]; + +export const loader = async ({ context }: Route.LoaderArgs) => { + const user = context.get(userContext); + return { user }; +}; + +export default function WithMiddlewarePage({ loaderData }: Route.ComponentProps) { + const { user } = loaderData; + + return ( +
+

With Middleware Page

+

User: {user?.name}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/sentry-flush.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/sentry-flush.tsx new file mode 100644 index 000000000000..c72024185046 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/sentry-flush.tsx @@ -0,0 +1,6 @@ +import * as Sentry from '@sentry/react-router'; + +export async function loader() { + await Sentry.flush(2000); + return new Response(null, { status: 204 }); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/docker-compose.yml b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/docker-compose.yml new file mode 100644 index 000000000000..d2193ccad07b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/docker-compose.yml @@ -0,0 +1,12 @@ +services: + redis: + image: redis:8 + restart: always + container_name: e2e-tests-react-router-7-static-redis + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 1s + timeout: 3s + retries: 30 diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/global-setup.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/global-setup.mjs new file mode 100644 index 000000000000..1e6210bfdc35 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/global-setup.mjs @@ -0,0 +1,21 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Boot Redis here (rather than in the `start` script) so the cold `redis:8` image +// pull happens outside Playwright's webServer startup-timeout window. `--wait` +// blocks until the healthcheck passes; +export default async function globalSetup() { + // Each run copies this app to a fresh temp dir, so `docker compose` doesn't + // recognize a leftover container from a previous (e.g. interrupted) run as + // part of the same project - but the container name is fixed, so the daemon + // still refuses to create a new one. Force-remove any stale leftover first. + try { + execSync('docker rm -f e2e-tests-react-router-7-static-redis', { stdio: 'ignore' }); + } catch { + // no stale container to remove + } + execSync('docker compose up -d --wait', { cwd: __dirname, stdio: 'inherit' }); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/instrument.mjs new file mode 100644 index 000000000000..00a6d2952286 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/react-router'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://username@domain/123', + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, + tunnel: `http://localhost:3031/`, // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json new file mode 100644 index 000000000000..b17cf84b46c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json @@ -0,0 +1,59 @@ +{ + "name": "react-router-7-framework-static", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.13.0", + "@react-router/node": "^7.13.0", + "@react-router/serve": "^7.13.0", + "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", + "ioredis": "^5.4.1", + "isbot": "^5.1.17" + }, + "devDependencies": { + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "@types/node": "^20", + "@react-router/dev": "^7.13.0", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.6.3", + "vite": "^5.4.11" + }, + "scripts": { + "build": "react-router build", + "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev", + "start": "NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js", + "proxy": "node start-event-proxy.mjs", + "typecheck": "react-router typegen && tsc", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:ts && pnpm test:playwright", + "test:ts": "pnpm typecheck", + "test:playwright": "playwright test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/playwright.config.mjs new file mode 100644 index 000000000000..70e62c2b9e3b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/playwright.config.mjs @@ -0,0 +1,13 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { fileURLToPath } from 'url'; + +const config = getPlaywrightConfig( + { + startCommand: `PORT=3030 pnpm start`, + port: 3030, + }, + // Boot Redis before the tests run, outside the webServer startup-timeout window. + { globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)) }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/public/favicon.ico b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/public/favicon.ico new file mode 100644 index 000000000000..5dbdfcddcb14 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/react-router.config.ts new file mode 100644 index 000000000000..72f2eef3b0f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/react-router.config.ts @@ -0,0 +1,9 @@ +import type { Config } from '@react-router/dev/config'; + +export default { + ssr: true, + prerender: ['/performance/static'], + future: { + v8_middleware: true, + }, +} satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/start-event-proxy.mjs new file mode 100644 index 000000000000..9efc50b2c508 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-framework-static', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/constants.ts new file mode 100644 index 000000000000..e0420ef6daf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/constants.ts @@ -0,0 +1 @@ +export const APP_NAME = 'react-router-7-framework-static'; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.client.test.ts new file mode 100644 index 000000000000..c1a7de46f1b6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.client.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client-side errors', () => { + const errorMessage = '¡Madre mía!'; + test('captures error thrown on click', async ({ page }) => { + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/client`); + await page.locator('#throw-on-click').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + }, + }, + ], + }, + transaction: '/errors/client', + request: { + url: expect.stringContaining('errors/client'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'javascript', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'browser' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + breadcrumbs: [ + { + category: 'ui.click', + message: 'body > div > button#throw-on-click', + }, + ], + }); + }); + + test('captures error thrown on click from a parameterized route', async ({ page }) => { + const errorMessage = '¡Madre mía de churros!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client/churros'); + await page.locator('#throw-on-click').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: '¡Madre mía de churros!', + mechanism: { + handled: false, + }, + }, + ], + }, + // todo: should be '/errors/client/:client-param' + transaction: '/errors/client/churros', + }); + }); + + test('captures error thrown in a clientLoader', async ({ page }) => { + const errorMessage = '¡Madre mía del client loader!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client-loader'); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'auto.function.react_router.on_error', + }, + }, + ], + }, + transaction: '/errors/client-loader', + }); + }); + + test('captures error thrown in a clientAction', async ({ page }) => { + const errorMessage = 'Madonna mia! Che casino nella Client Action!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client-action'); + await page.locator('#submit').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'auto.function.react_router.on_error', + }, + }, + ], + }, + transaction: '/errors/client-action', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.server.test.ts new file mode 100644 index 000000000000..2759bfecb67e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.server.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server-side errors', () => { + test('captures error thrown in server loader', async ({ page }) => { + const errorMessage = '¡Madre mía del server!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/server-loader`); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'react-router', + }, + }, + ], + }, + // todo: should be 'GET /errors/server-loader' + transaction: 'GET *', + request: { + url: expect.stringContaining('errors/server-loader'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'node', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'node' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); + + test('captures error thrown in server action', async ({ page }) => { + const errorMessage = 'Madonna mia! Che casino nella Server Action!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/server-action`); + await page.locator('#submit').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'react-router', + }, + }, + ], + }, + // todo: should be 'POST /errors/server-action' + transaction: 'POST *', + request: { + url: expect.stringContaining('errors/server-action'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'node', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'node' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/low-quality-filter.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/low-quality-filter.server.test.ts new file mode 100644 index 000000000000..0664ac5c99e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/low-quality-filter.server.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('low-quality transaction filter', () => { + test('does not send a server transaction for /__manifest? requests', async ({ page }) => { + const serverTxns: Array<{ contexts?: { trace?: { data?: Record } } }> = []; + + const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + waitForTransaction(APP_NAME, async evt => { + serverTxns.push(evt); + return false; + }); + + await page.goto('/performance'); + await page.waitForTimeout(1000); + await page.getByRole('link', { name: 'SSR Page' }).click(); + + await navigationPromise; + + // Force the server to flush any in-flight transactions before we assert + await page.evaluate(() => fetch('/__sentry-flush')); + + const targetIsManifest = (t: (typeof serverTxns)[number]) => + typeof t.contexts?.trace?.data?.['url.path'] === 'string' && + (t.contexts.trace.data['url.path'] as string).includes('/__manifest'); + expect(serverTxns.some(targetIsManifest)).toBe(false); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/middleware.server.test.ts new file mode 100644 index 000000000000..dbce05350ad9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/middleware.server.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server - middleware', () => { + test('should send middleware transaction on pageload', async ({ page }) => { + const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with-middleware'; + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/with-middleware'; + }); + + const customMiddlewareTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'authMiddleware'; + }); + + await page.goto(`/performance/with-middleware`); + + const serverTx = await serverTxPromise; + const pageloadTx = await pageloadTxPromise; + const customMiddlewareTx = await customMiddlewareTxPromise; + + const traceIds = { + server: serverTx?.contexts?.trace?.trace_id, + pageload: pageloadTx?.contexts?.trace?.trace_id, + customMiddleware: customMiddlewareTx?.contexts?.trace?.trace_id, + }; + + expect(pageloadTx).toBeDefined(); + expect(customMiddlewareTx).toBeDefined(); + + // Assert that all transactions belong to the same trace + expect(traceIds.server).toBe(traceIds.pageload); + expect(traceIds.server).toBe(traceIds.customMiddleware); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/navigation.client.test.ts new file mode 100644 index 000000000000..59b2f8a9e038 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/navigation.client.test.ts @@ -0,0 +1,243 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client - navigation performance', () => { + test('should create navigation transaction', async ({ page }) => { + const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); // pageload + await pageloadTxPromise; + await page.getByRole('link', { name: 'SSR Page' }).click(); // navigation + + const transaction = await navigationPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.navigation.react_router', + 'sentry.op': 'navigation', + 'sentry.segment.name.source': 'route', + 'url.template': '/performance/ssr', + 'url.path': '/performance/ssr', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/ssr$/), + }, + op: 'navigation', + origin: 'auto.navigation.react_router', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/ssr', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'javascript', + request: { + url: expect.stringContaining('/performance/ssr'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + test('should create navigation transaction when navigating with object `to` prop', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); // pageload + await pageloadTxPromise; + await page.getByRole('link', { name: 'Object Navigate' }).click(); // navigation with object to + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react_router', + data: { + 'sentry.segment.name.source': 'route', + 'url.template': '/performance/with/:param', + 'url.path': '/performance/with/object-nav', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/object-nav\?foo=bar$/), + }, + }, + }, + transaction: '/performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + }); + }); + + test('should create navigation transaction when navigating with search-only object `to` prop', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); // pageload + await pageloadTxPromise; + await page.getByRole('link', { name: 'Search Only Navigate' }).click(); // navigation with search-only object to + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react_router', + data: { + 'url.template': '/performance', + // the initial pageload to `/performance` gets 301-redirected to a trailing slash by react-router-serve + 'url.path': '/performance/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/\?query=test$/), + }, + }, + }, + transaction: '/performance', + type: 'transaction', + }); + }); + + test('should update navigation transaction for dynamic routes', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); // pageload + await pageloadTxPromise; + await page.getByRole('link', { name: 'With Param Page' }).click(); // navigation + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.navigation.react_router', + 'sentry.op': 'navigation', + 'sentry.segment.name.source': 'route', + 'url.template': '/performance/with/:param', + 'url.path': '/performance/with/sentry', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), + }, + op: 'navigation', + origin: 'auto.navigation.react_router', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'javascript', + request: { + url: expect.stringContaining('/performance/with/sentry'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + test('should create navigation transaction for navigate(-1) with correct url attributes', async ({ page }) => { + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); + await pageloadTxPromise; + + const forwardNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + await page.getByRole('link', { name: 'SSR Page' }).click(); + await forwardNavPromise; + + const backNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.getByRole('button', { name: 'History Back Navigate' }).click(); + + const transaction = await backNavPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react_router', + data: { + 'sentry.segment.name.source': 'route', + 'sentry.op': 'navigation', + 'sentry.origin': 'auto.navigation.react_router', + 'url.template': '/performance', + // react-router-serve 301-redirects the bare index route to a trailing slash + 'url.path': '/performance/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), + }, + }, + }, + transaction: '/performance', + type: 'transaction', + transaction_info: { source: 'route' }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/pageload.client.test.ts new file mode 100644 index 000000000000..e3bea05a36ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/pageload.client.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client - pageload performance', () => { + test('should send pageload transaction', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.pageload.react_router', + 'sentry.op': 'pageload', + 'sentry.segment.name.source': 'route', + 'url.template': '/performance', + // react-router-serve 301-redirects the bare index route to a trailing slash + 'url.path': '/performance/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), + }, + op: 'pageload', + origin: 'auto.pageload.react_router', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance', + type: 'transaction', + transaction_info: { source: 'route' }, + measurements: expect.any(Object), + platform: 'javascript', + request: { + url: expect.stringContaining('/performance'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + test('should update pageload transaction for dynamic routes', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/performance/with/sentry`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.pageload.react_router', + 'sentry.op': 'pageload', + 'sentry.segment.name.source': 'route', + 'url.template': '/performance/with/:param', + 'url.path': '/performance/with/sentry', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), + }, + op: 'pageload', + origin: 'auto.pageload.react_router', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + measurements: expect.any(Object), + platform: 'javascript', + request: { + url: expect.stringContaining('/performance/with/sentry'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + test('should send pageload transaction for prerendered pages', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/performance/static`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + transaction: '/performance/static', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.pageload.react_router', + 'sentry.op': 'pageload', + 'sentry.segment.name.source': 'route', + 'url.template': '/performance/static', + // react-router-serve 301-redirects prerendered routes to a trailing slash + 'url.path': '/performance/static/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/static\/$/), + }, + op: 'pageload', + origin: 'auto.pageload.react_router', + }, + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/performance.server.test.ts new file mode 100644 index 000000000000..bcfe15e49c34 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/performance.server.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server - performance', () => { + test('should send server transaction on pageload', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance'; + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.request_handler', + 'sentry.segment.name.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.request_handler', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /performance', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'node', + request: { + url: expect.stringContaining('/performance'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/node', version: expect.any(String) }, + ], + }, + tags: { + runtime: 'node', + }, + }); + }); + + test('should send server transaction on parameterized route', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with/:param'; + }); + + await page.goto(`/performance/with/some-param`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.request_handler', + 'sentry.segment.name.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.request_handler', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'node', + request: { + url: expect.stringContaining('/performance/with/some-param'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/node', version: expect.any(String) }, + ], + }, + tags: { + runtime: 'node', + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/redis.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/redis.server.test.ts new file mode 100644 index 000000000000..1b82a709ab7b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/redis.server.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server - redis db spans', () => { + test('server loader emits db.query child spans on the http.server transaction', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === 'GET /performance/redis' && + (transactionEvent.spans?.some(span => span.op === 'db.query') ?? false) + ); + }); + + await page.goto('/performance/redis'); + + const transaction = await txPromise; + + expect(transaction.contexts?.trace?.op).toBe('http.server'); + + // Collect every span id in the transaction (root + children) so we can verify nesting. + const rootSpanId = transaction.contexts?.trace?.span_id; + const spanIds = new Set([rootSpanId, ...(transaction.spans ?? []).map(span => span.span_id)]); + + const redisSpans = transaction.spans!.filter(span => span.op === 'db.query'); + + // loader runs SET then GET => at least two redis command spans + expect(redisSpans.length).toBeGreaterThanOrEqual(2); + + // every redis span nests under the http.server transaction (its parent is part of the same span tree) + const allNested = redisSpans.every( + span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id), + ); + expect(allNested).toBe(true); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts new file mode 100644 index 000000000000..b62e784ff3e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('Trace propagation', () => { + test('should inject metatags in ssr pageload', async ({ page }) => { + await page.goto(`/`); + const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content'); + expect(sentryTraceContent).toBeDefined(); + expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); + const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content'); + expect(baggageContent).toBeDefined(); + expect(baggageContent).toContain('sentry-environment=qa'); + expect(baggageContent).toContain('sentry-public_key='); + expect(baggageContent).toContain('sentry-trace_id='); + expect(baggageContent).toContain('sentry-transaction='); + expect(baggageContent).toContain('sentry-sampled='); + }); + + test('should have trace connection', async ({ page }) => { + const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET *'; + }); + + const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/'; + }); + + await page.goto(`/`); + const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; + + expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); + + const requestHandlerSpan = serverTx.spans?.find(span => span.op === 'handler'); + + expect(requestHandlerSpan).toBeDefined(); + expect(clientTx.contexts?.trace?.parent_span_id).toBe(requestHandlerSpan?.span_id); + }); + + test('should not have trace connection for prerendered pages', async ({ page }) => { + await page.goto('/performance/static'); + + const sentryTraceElement = await page.$('meta[name="sentry-trace"]'); + expect(sentryTraceElement).toBeNull(); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tsconfig.json new file mode 100644 index 000000000000..a16df276e8bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + }, + "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/vite.config.ts new file mode 100644 index 000000000000..68ba30d69397 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/vite.config.ts @@ -0,0 +1,6 @@ +import { reactRouter } from '@react-router/dev/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [reactRouter()], +});