diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/.gitignore b/dev-packages/e2e-tests/test-applications/react-create-browser-router/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/.gitignore @@ -0,0 +1,29 @@ +# 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 diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/.npmrc b/dev-packages/e2e-tests/test-applications/react-create-browser-router/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/package.json b/dev-packages/e2e-tests/test-applications/react-create-browser-router/package.json new file mode 100644 index 000000000000..a4e7dae6d1e2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/package.json @@ -0,0 +1,40 @@ +{ + "name": "react-create-browser-router-test", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "latest || *", + "@types/node": "^18.19.1", + "@types/react": "18.0.0", + "@types/react-dom": "18.0.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "^6.4.1", + "react-scripts": "5.0.1", + "typescript": "~5.0.0" + }, + "scripts": { + "build": "react-scripts build", + "start": "serve -s build", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && pnpm build", + "test:assert": "pnpm 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"] + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils", + "serve": "14.0.1" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-create-browser-router/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/public/index.html b/dev-packages/e2e-tests/test-applications/react-create-browser-router/public/index.html new file mode 100644 index 000000000000..39da76522bea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/public/index.html @@ -0,0 +1,24 @@ + + + + + + + + React App + + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/globals.d.ts @@ -0,0 +1,5 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + sentryReplayId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx new file mode 100644 index 000000000000..88f8cfa502ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx @@ -0,0 +1,66 @@ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { + RouterProvider, + createBrowserRouter, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router-dom'; +import Index from './pages/Index'; +import User from './pages/User'; + +const replay = Sentry.replayIntegration(); + +Sentry.init({ + // environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.REACT_APP_E2E_TEST_DSN, + integrations: [ + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + replay, + ], + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + release: 'e2e-test', + + tunnel: 'http://localhost:3031', + + // Always capture replays, so we can test this properly + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + debug: !!process.env.DEBUG, +}); + +const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter); + +const router = sentryCreateBrowserRouter( + [ + { + path: '/', + element: , + }, + { + path: '/user/:id', + element: , + }, + ], + { + // We're testing whether this option is avoided in the integration + // We expect this to be ignored + initialEntries: ['/user/1'], + }, +); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); + +root.render(); diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/Index.tsx new file mode 100644 index 000000000000..d6b71a1d1279 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/Index.tsx @@ -0,0 +1,23 @@ +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; +import { Link } from 'react-router-dom'; + +const Index = () => { + return ( + <> + { + throw new Error('I am an error!'); + }} + /> + + navigate + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/User.tsx new file mode 100644 index 000000000000..62f0c2d17533 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/pages/User.tsx @@ -0,0 +1,8 @@ +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; + +const User = () => { + return

I am a blank page :)

; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/react-app-env.d.ts new file mode 100644 index 000000000000..6431bc5fc6b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-create-browser-router/start-event-proxy.mjs new file mode 100644 index 000000000000..be93e129284f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-create-browser-router', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/errors.test.ts new file mode 100644 index 000000000000..4a11f07410ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/errors.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Captures exception correctly', async ({ page }) => { + const errorEventPromise = waitForError('react-create-browser-router', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts new file mode 100644 index 000000000000..5ecd098daf94 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Captures a pageload transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-browser-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(transactionEvent.contexts?.trace).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + deviceMemory: expect.any(String), + effectiveConnectionType: expect.any(String), + hardwareConcurrency: expect.any(String), + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.react.reactrouter_v6', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + }), + op: 'pageload', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.pageload.react.reactrouter_v6', + }), + ); +}); + +test('Captures a navigation transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-browser-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'navigation', + 'sentry.origin': 'auto.navigation.react.reactrouter_v6', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + }), + op: 'navigation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.navigation.react.reactrouter_v6', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/user/:id', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(transactionEvent.spans).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tsconfig.json new file mode 100644 index 000000000000..4cc95dc2689a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "include": ["src", "tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/.gitignore b/dev-packages/e2e-tests/test-applications/react-create-memory-router/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/.gitignore @@ -0,0 +1,29 @@ +# 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 diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/.npmrc b/dev-packages/e2e-tests/test-applications/react-create-memory-router/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/package.json b/dev-packages/e2e-tests/test-applications/react-create-memory-router/package.json new file mode 100644 index 000000000000..dc6c9b4340f0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/package.json @@ -0,0 +1,40 @@ +{ + "name": "react-create-memory-router-test", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "latest || *", + "@types/node": "^18.19.1", + "@types/react": "18.0.0", + "@types/react-dom": "18.0.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "^6.4.1", + "react-scripts": "5.0.1", + "typescript": "~5.0.0" + }, + "scripts": { + "build": "react-scripts build", + "start": "serve -s build", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && pnpm build", + "test:assert": "pnpm 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"] + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils", + "serve": "14.0.1" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-create-memory-router/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/public/index.html b/dev-packages/e2e-tests/test-applications/react-create-memory-router/public/index.html new file mode 100644 index 000000000000..39da76522bea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/public/index.html @@ -0,0 +1,24 @@ + + + + + + + + React App + + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/globals.d.ts @@ -0,0 +1,5 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + sentryReplayId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/index.tsx new file mode 100644 index 000000000000..f71572f9dc1f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/index.tsx @@ -0,0 +1,65 @@ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { + RouterProvider, + createMemoryRouter, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router-dom'; +import Index from './pages/Index'; +import User from './pages/User'; + +const replay = Sentry.replayIntegration(); + +Sentry.init({ + // environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.REACT_APP_E2E_TEST_DSN, + integrations: [ + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + replay, + ], + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + release: 'e2e-test', + + tunnel: 'http://localhost:3031', + + // Always capture replays, so we can test this properly + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + debug: !!process.env.DEBUG, +}); + +const sentryCreateMemoryRouter = Sentry.wrapCreateMemoryRouterV6(createMemoryRouter); + +const router = sentryCreateMemoryRouter( + [ + { + path: '/', + element: , + }, + { + path: '/user/:id', + element: , + }, + ], + { + initialEntries: ['/', '/user/1', '/user/2'], + initialIndex: 2, + }, +); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); + +root.render(); diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/pages/Index.tsx new file mode 100644 index 000000000000..b025f721e100 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/pages/Index.tsx @@ -0,0 +1,19 @@ +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; + +const Index = () => { + return ( + <> + { + throw new Error('I am an error!'); + }} + /> + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/pages/User.tsx new file mode 100644 index 000000000000..e54d6c604e2d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/pages/User.tsx @@ -0,0 +1,19 @@ +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; +import { Link } from 'react-router-dom'; + +const User = () => { + return ( +
+ + Home + + + navigate + +

I am a blank page :)

; +
+ ); +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/react-app-env.d.ts new file mode 100644 index 000000000000..6431bc5fc6b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-create-memory-router/start-event-proxy.mjs new file mode 100644 index 000000000000..9c451610f4c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-create-memory-router', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/errors.test.ts new file mode 100644 index 000000000000..9406ca63e30c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/errors.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Captures exception correctly', async ({ page }) => { + const errorEventPromise = waitForError('react-create-memory-router', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + + // We're on the user page, navigate back to the home page + const homeButton = page.locator('id=home-button'); + await homeButton.click(); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts new file mode 100644 index 000000000000..7c75c395c3af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Captures a pageload transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-memory-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/user/:id', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(transactionEvent.contexts?.trace).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.react.reactrouter_v6', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + }), + op: 'pageload', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.pageload.react.reactrouter_v6', + }), + ); +}); + +test('Captures a navigation transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-memory-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-button'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'navigation', + 'sentry.origin': 'auto.navigation.react.reactrouter_v6', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + }), + op: 'navigation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.navigation.react.reactrouter_v6', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/user/:id', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + expect(transactionEvent.spans).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tsconfig.json new file mode 100644 index 000000000000..4cc95dc2689a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "include": ["src", "tests"] +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9481e37789ec..ae441a66837b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -18,10 +18,12 @@ export { withSentryReactRouterV6Routing, wrapUseRoutesV6, wrapCreateBrowserRouterV6, + wrapCreateMemoryRouterV6, } from './reactrouterv6'; export { reactRouterV7BrowserTracingIntegration, withSentryReactRouterV7Routing, wrapCreateBrowserRouterV7, + wrapCreateMemoryRouterV7, wrapUseRoutesV7, } from './reactrouterv7'; diff --git a/packages/react/src/reactrouterv6-compat-utils.tsx b/packages/react/src/reactrouterv6-compat-utils.tsx index 06de135dda40..9ca96a03b0de 100644 --- a/packages/react/src/reactrouterv6-compat-utils.tsx +++ b/packages/react/src/reactrouterv6-compat-utils.tsx @@ -80,10 +80,7 @@ export function createV6CompatibleWrapCreateBrowserRouter< return createRouterFunction; } - // `opts` for createBrowserHistory and createMemoryHistory are different, but also not relevant for us at the moment. - // `basename` is the only option that is relevant for us, and it is the same for all. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter { + return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter { const router = createRouterFunction(routes, opts); const basename = opts?.basename; @@ -113,6 +110,78 @@ export function createV6CompatibleWrapCreateBrowserRouter< }; } +/** + * Creates a wrapCreateMemoryRouter function that can be used with all React Router v6 compatible versions. + */ +export function createV6CompatibleWrapCreateMemoryRouter< + TState extends RouterState = RouterState, + TRouter extends Router = Router, +>( + createRouterFunction: CreateRouterFunction, + version: V6CompatibleVersion, +): CreateRouterFunction { + if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) { + DEBUG_BUILD && + logger.warn( + `reactRouterV${version}Instrumentation was unable to wrap the \`createMemoryRouter\` function because of one or more missing parameters.`, + ); + + return createRouterFunction; + } + + return function ( + routes: RouteObject[], + opts?: Record & { + basename?: string; + initialEntries?: (string | { pathname: string })[]; + initialIndex?: number; + }, + ): TRouter { + const router = createRouterFunction(routes, opts); + const basename = opts?.basename; + + const activeRootSpan = getActiveRootSpan(); + let initialEntry = undefined; + + const initialEntries = opts?.initialEntries; + const initialIndex = opts?.initialIndex; + + const hasOnlyOneInitialEntry = initialEntries && initialEntries.length === 1; + const hasIndexedEntry = initialIndex !== undefined && initialEntries && initialEntries[initialIndex]; + + initialEntry = hasOnlyOneInitialEntry + ? initialEntries[0] + : hasIndexedEntry + ? initialEntries[initialIndex] + : undefined; + + const location = initialEntry + ? typeof initialEntry === 'string' + ? { pathname: initialEntry } + : initialEntry + : router.state.location; + + if (router.state.historyAction === 'POP' && activeRootSpan) { + updatePageloadTransaction(activeRootSpan, location, routes, undefined, basename); + } + + router.subscribe((state: RouterState) => { + const location = state.location; + if (state.historyAction === 'PUSH' || state.historyAction === 'POP') { + handleNavigation({ + location, + routes, + navigationType: state.historyAction, + version, + basename, + }); + } + }); + + return router; + }; +} + /** * Creates a browser tracing integration that can be used with all React Router v6 compatible versions. */ diff --git a/packages/react/src/reactrouterv6.tsx b/packages/react/src/reactrouterv6.tsx index 6faaa2e6f65a..a32e2bb02bf1 100644 --- a/packages/react/src/reactrouterv6.tsx +++ b/packages/react/src/reactrouterv6.tsx @@ -5,6 +5,7 @@ import { createReactRouterV6CompatibleTracingIntegration, createV6CompatibleWithSentryReactRouterRouting, createV6CompatibleWrapCreateBrowserRouter, + createV6CompatibleWrapCreateMemoryRouter, createV6CompatibleWrapUseRoutes, } from './reactrouterv6-compat-utils'; import type { CreateRouterFunction, Router, RouterState, UseRoutes } from './types'; @@ -38,6 +39,19 @@ export function wrapCreateBrowserRouterV6< return createV6CompatibleWrapCreateBrowserRouter(createRouterFunction, '6'); } +/** + * A wrapper function that adds Sentry routing instrumentation to a React Router v6 createMemoryRouter function. + * This is used to automatically capture route changes as transactions when using the createMemoryRouter API. + * The difference between createBrowserRouter and createMemoryRouter is that with createMemoryRouter, + * optional `initialEntries` are also taken into account. + */ +export function wrapCreateMemoryRouterV6< + TState extends RouterState = RouterState, + TRouter extends Router = Router, +>(createMemoryRouterFunction: CreateRouterFunction): CreateRouterFunction { + return createV6CompatibleWrapCreateMemoryRouter(createMemoryRouterFunction, '6'); +} + /** * A higher-order component that adds Sentry routing instrumentation to a React Router v6 Route component. * This is used to automatically capture route changes as transactions. diff --git a/packages/react/src/reactrouterv7.tsx b/packages/react/src/reactrouterv7.tsx index df2badd35e44..5a80482cd2c3 100644 --- a/packages/react/src/reactrouterv7.tsx +++ b/packages/react/src/reactrouterv7.tsx @@ -6,6 +6,7 @@ import { createReactRouterV6CompatibleTracingIntegration, createV6CompatibleWithSentryReactRouterRouting, createV6CompatibleWrapCreateBrowserRouter, + createV6CompatibleWrapCreateMemoryRouter, createV6CompatibleWrapUseRoutes, } from './reactrouterv6-compat-utils'; import type { CreateRouterFunction, Router, RouterState, UseRoutes } from './types'; @@ -40,6 +41,19 @@ export function wrapCreateBrowserRouterV7< return createV6CompatibleWrapCreateBrowserRouter(createRouterFunction, '7'); } +/** + * A wrapper function that adds Sentry routing instrumentation to a React Router v7 createMemoryRouter function. + * This is used to automatically capture route changes as transactions when using the createMemoryRouter API. + * The difference between createBrowserRouter and createMemoryRouter is that with createMemoryRouter, + * optional `initialEntries` are also taken into account. + */ +export function wrapCreateMemoryRouterV7< + TState extends RouterState = RouterState, + TRouter extends Router = Router, +>(createMemoryRouterFunction: CreateRouterFunction): CreateRouterFunction { + return createV6CompatibleWrapCreateMemoryRouter(createMemoryRouterFunction, '7'); +} + /** * A wrapper function that adds Sentry routing instrumentation to a React Router v7 useRoutes hook. * This is used to automatically capture route changes as transactions when using the useRoutes hook. diff --git a/packages/react/test/reactrouterv6.test.tsx b/packages/react/test/reactrouterv6.test.tsx index 3b9e9e42a4c7..879abf15d2d0 100644 --- a/packages/react/test/reactrouterv6.test.tsx +++ b/packages/react/test/reactrouterv6.test.tsx @@ -13,7 +13,9 @@ import { Navigate, Outlet, Route, + RouterProvider, Routes, + createMemoryRouter, createRoutesFromChildren, matchRoutes, useLocation, @@ -21,10 +23,13 @@ import { useRoutes, } from 'react-router-6'; +import type { RouteObject } from 'react-router-6'; + import { BrowserClient } from '../src'; import { reactRouterV6BrowserTracingIntegration, withSentryReactRouterV6Routing, + wrapCreateMemoryRouterV6, wrapUseRoutesV6, } from '../src/reactrouterv6'; @@ -79,6 +84,99 @@ describe('reactRouterV6BrowserTracingIntegration', () => { getCurrentScope().setClient(undefined); }); + it('wrapCreateMemoryRouterV6 starts and updates a pageload transaction - single initialEntry', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const routes: RouteObject[] = [ + { + path: '/', + element:
Home
, + }, + { + path: '/about', + element:
About
, + }, + ]; + + const wrappedCreateMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = wrappedCreateMemoryRouter(routes, { + initialEntries: ['/about'], + }); + + render(); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', + }, + }); + + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/about'); + }); + + it('wrapCreateMemoryRouterV6 starts and updates a pageload transaction - multiple initialEntries', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const routes: RouteObject[] = [ + { + path: '/', + element:
Home
, + }, + { + path: '/about', + element:
About
, + }, + ]; + + const wrappedCreateMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = wrappedCreateMemoryRouter(routes, { + initialEntries: ['/', '/about'], + initialIndex: 1, + }); + + render(); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', + }, + }); + + expect(mockRootSpan.updateName).toHaveBeenLastCalledWith('/about'); + }); + describe('withSentryReactRouterV6Routing', () => { it('starts a pageload transaction', () => { const client = createMockBrowserClient();