diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7115aa7a15b..1027efa79677 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1008,6 +1008,7 @@ jobs: 'nextjs-14', 'react-create-hash-router', 'react-router-6-use-routes', + 'react-router-5', 'standard-frontend-react', 'svelte-5', 'sveltekit', diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-5/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/.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-router-5/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-5/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/.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-router-5/package.json b/dev-packages/e2e-tests/test-applications/react-router-5/package.json new file mode 100644 index 000000000000..921f67e212b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/package.json @@ -0,0 +1,60 @@ +{ + "name": "react-router-5-e2e-test-app", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "latest || *", + "@testing-library/jest-dom": "5.14.1", + "@testing-library/react": "13.0.0", + "@testing-library/user-event": "13.2.1", + "history": "4.9.0", + "@types/history": "4.7.11", + "@types/jest": "27.0.1", + "@types/node": "16.7.13", + "@types/react": "18.0.0", + "@types/react-dom": "18.0.0", + "@types/react-router": "5.1.20", + "@types/react-router-dom": "5.3.3", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-router-dom": "5.3.4", + "react-scripts": "5.0.1", + "typescript": "4.9.5", + "web-vitals": "2.1.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 && npx playwright install && 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.43.1", + "@sentry-internal/event-proxy-server": "link:../../../event-proxy-server", + "ts-node": "^10.9.2", + "serve": "14.0.1" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/playwright.config.ts b/dev-packages/e2e-tests/test-applications/react-router-5/playwright.config.ts new file mode 100644 index 000000000000..abee4975ed4e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/playwright.config.ts @@ -0,0 +1,82 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +const reactPort = 3030; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + baseURL: `http://localhost:${reactPort}`, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + // For now we only test Chrome! + // { + // name: 'firefox', + // use: { + // ...devices['Desktop Firefox'], + // }, + // }, + // { + // name: 'webkit', + // use: { + // ...devices['Desktop Safari'], + // }, + // }, + ], + + /* Run your local dev server before starting the tests */ + + webServer: [ + { + command: 'pnpm ts-node-script start-event-proxy.ts', + port: eventProxyPort, + }, + { + command: 'pnpm start', + port: reactPort, + env: { + PORT: `${reactPort}`, + }, + }, + ], +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/public/index.html b/dev-packages/e2e-tests/test-applications/react-router-5/public/index.html new file mode 100644 index 000000000000..39da76522bea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/public/index.html @@ -0,0 +1,24 @@ + + + + + + + + React App + + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-5/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/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-router-5/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx new file mode 100644 index 000000000000..315ba07ad8c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx @@ -0,0 +1,42 @@ +import * as Sentry from '@sentry/react'; +import { createBrowserHistory } from 'history'; +// biome-ignore lint/nursery/noUnusedImports: +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { Route, Router, Switch } from 'react-router-dom'; +import Index from './pages/Index'; +import User from './pages/User'; + +const replay = Sentry.replayIntegration(); + +const history = createBrowserHistory(); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: + process.env.REACT_APP_E2E_TEST_DSN || + 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576', + integrations: [Sentry.reactRouterV5BrowserTracingIntegration({ history }), replay], + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + release: 'e2e-test', + tunnel: 'http://localhost:3031/', // proxy server + + // Always capture replays, so we can test this properly + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, +}); + +// Create Custom Sentry Route component +export const SentryRoute = Sentry.withSentryRouting(Route); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render( + + + + + + , +); diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-5/src/pages/Index.tsx new file mode 100644 index 000000000000..7789a2773224 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/src/pages/Index.tsx @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/react'; +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; +import { Link } from 'react-router-dom'; + +const Index = () => { + return ( + <> + { + const eventId = Sentry.captureException(new Error('I am an error!')); + window.capturedExceptionId = eventId; + }} + /> + + navigate + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-router-5/src/pages/User.tsx new file mode 100644 index 000000000000..3b41552d35d3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/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 = (params: { id: string }) => { + return

Show user details for {params.id}

; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-router-5/src/react-app-env.d.ts new file mode 100644 index 000000000000..6431bc5fc6b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/start-event-proxy.ts b/dev-packages/e2e-tests/test-applications/react-router-5/start-event-proxy.ts new file mode 100644 index 000000000000..4b18df9aacaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/start-event-proxy.ts @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-5', +}); 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 new file mode 100644 index 000000000000..bcfafe8b6624 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/event-proxy-server'; + +test('Sends correct error event', async ({ page }) => { + const errorEventPromise = waitForError('react-router-5', 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.any(String), + span_id: expect.any(String), + }); +}); + +test('Sets correct transactionName', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-5', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const errorEventPromise = waitForError('react-router-5', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const transactionEvent = await transactionPromise; + + // Only capture error once transaction was sent + 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.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: transactionEvent.contexts?.trace?.trace_id, + span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + }); +}); 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 new file mode 100644 index 000000000000..e13c4702dc55 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/event-proxy-server'; + +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', + }, + }, + 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', + }, + }, + transaction: '/user/:id', + transaction_info: { + source: 'route', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-5/tsconfig.json new file mode 100644 index 000000000000..c137d51512ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/tsconfig.json @@ -0,0 +1,25 @@ +{ + "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"], + "ts-node": { + "compilerOptions": { + "module": "CommonJS" + } + } +} diff --git a/packages/react/src/reactrouter.tsx b/packages/react/src/reactrouter.tsx index 3fe40df8ece8..3adf59326012 100644 --- a/packages/react/src/reactrouter.tsx +++ b/packages/react/src/reactrouter.tsx @@ -224,14 +224,15 @@ function computeRootMatch(pathname: string): Match { export function withSentryRouting

, R extends React.ComponentType

>(Route: R): R { const componentDisplayName = (Route as any).displayName || (Route as any).name; - const activeRootSpan = getActiveRootSpan(); - const WrappedRoute: React.FC

= (props: P) => { if (props && props.computedMatch && props.computedMatch.isExact) { - getCurrentScope().setTransactionName(props.computedMatch.path); + const route = props.computedMatch.path; + const activeRootSpan = getActiveRootSpan(); + + getCurrentScope().setTransactionName(route); if (activeRootSpan) { - activeRootSpan.updateName(props.computedMatch.path); + activeRootSpan.updateName(route); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); } }