diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-spa/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/.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-7-spa/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-7-spa/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/.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-7-spa/index.html b/dev-packages/e2e-tests/test-applications/react-router-7-spa/index.html new file mode 100644 index 000000000000..e4b78eae1230 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/index.html @@ -0,0 +1,13 @@ + + +
+ + + +I am a blank page :)
; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-spa/start-event-proxy.mjs new file mode 100644 index 000000000000..cd75ae3ae830 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-spa', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts new file mode 100644 index 000000000000..e31d3c4066d4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page, baseURL }) => { + const errorEventPromise = waitForError('react-router-7-spa', 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-7-spa', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const errorEventPromise = waitForError('react-router-7-spa', 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-7-spa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts new file mode 100644 index 000000000000..c915d3694742 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem, waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-spa', 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_v7', + }, + }, + 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-7-spa', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('react-router-7-spa', 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_v7', + }, + }, + transaction: '/user/:id', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends an INP span', async ({ page }) => { + const inpSpanPromise = waitForEnvelopeItem('react-router-7-spa', item => { + return item[0].type === 'span'; + }); + + await page.goto(`/`); + + await page.click('#exception-button'); + + await page.waitForTimeout(500); + + // Page hide to trigger INP + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + const inpSpan = await inpSpanPromise; + + expect(inpSpan[1]).toEqual({ + data: { + 'sentry.origin': 'auto.http.browser.inp', + 'sentry.op': 'ui.interaction.click', + release: 'e2e-test', + environment: 'qa', + transaction: '/', + 'sentry.exclusive_time': expect.any(Number), + replay_id: expect.any(String), + 'user_agent.original': expect.stringContaining('Chrome'), + }, + description: 'body > div#root > input#exception-button[type="button"]', + op: 'ui.interaction.click', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'auto.http.browser.inp', + exclusive_time: expect.any(Number), + measurements: { inp: { unit: 'millisecond', value: expect.any(Number) } }, + segment_id: expect.any(String), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tsconfig.json new file mode 100644 index 000000000000..60051df13cbf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tsconfig.json @@ -0,0 +1,21 @@ +{ + "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", + "types": ["vite/client"], + }, + "include": ["src", "tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/vite.config.ts new file mode 100644 index 000000000000..63c2c4317df7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/vite.config.ts @@ -0,0 +1,8 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + envPrefix: 'PUBLIC_', +}); diff --git a/docs/migration/draft-v9-migration-guide.md b/docs/migration/draft-v9-migration-guide.md index b70b314d8f98..3b915d45dd30 100644 --- a/docs/migration/draft-v9-migration-guide.md +++ b/docs/migration/draft-v9-migration-guide.md @@ -99,6 +99,11 @@ - Deprecated `autoInstrumentRemix: false`. The next major version will default to behaving as if this option were `true` and the option itself will be removed. +## `@sentry/react` + +- Deprecated `wrapUseRoutes`. Use `wrapUseRoutesV6` or `wrapUseRoutesV7` instead. +- Deprecated `wrapCreateBrowserRouter`. Use `wrapCreateBrowserRouterV6` or `wrapCreateBrowserRouterV7` instead. + ## `@sentry/opentelemetry` - Deprecated `generateSpanContextForPropagationContext` in favor of doing this manually - we do not need this export anymore. diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 5fe088ec448b..e72eb09645ec 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -16,6 +16,16 @@ export { export { reactRouterV6BrowserTracingIntegration, withSentryReactRouterV6Routing, + // eslint-disable-next-line deprecation/deprecation wrapUseRoutes, + wrapUseRoutesV6, + // eslint-disable-next-line deprecation/deprecation wrapCreateBrowserRouter, + wrapCreateBrowserRouterV6, } from './reactrouterv6'; +export { + reactRouterV7BrowserTracingIntegration, + withSentryReactRouterV7Routing, + wrapCreateBrowserRouterV7, + wrapUseRoutesV7, +} from './reactrouterv7'; diff --git a/packages/react/src/reactrouterv6-compat-utils.tsx b/packages/react/src/reactrouterv6-compat-utils.tsx new file mode 100644 index 000000000000..7752e49d69a1 --- /dev/null +++ b/packages/react/src/reactrouterv6-compat-utils.tsx @@ -0,0 +1,436 @@ +/* eslint-disable max-lines */ +// Inspired from Donnie McNeal's solution: +// https://gist.github.com/wontondon/e8c4bdf2888875e4c755712e99279536 + +import { + WINDOW, + browserTracingIntegration, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, +} from '@sentry/browser'; +import type { Client, Integration, Span, TransactionSource } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + getActiveSpan, + getClient, + getCurrentScope, + getNumberOfUrlSegments, + getRootSpan, + logger, + spanToJSON, +} from '@sentry/core'; +import * as React from 'react'; + +import hoistNonReactStatics from 'hoist-non-react-statics'; +import { DEBUG_BUILD } from './debug-build'; +import type { + Action, + AgnosticDataRouteMatch, + CreateRouterFunction, + CreateRoutesFromChildren, + Location, + MatchRoutes, + RouteMatch, + RouteObject, + Router, + RouterState, + UseEffect, + UseLocation, + UseNavigationType, + UseRoutes, +} from './types'; + +let _useEffect: UseEffect; +let _useLocation: UseLocation; +let _useNavigationType: UseNavigationType; +let _createRoutesFromChildren: CreateRoutesFromChildren; +let _matchRoutes: MatchRoutes; +let _stripBasename: boolean = false; + +const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet, R extends React.FC
>( + Routes: R, + version: V6CompatibleVersion, +): R { + if (!_useEffect || !_useLocation || !_useNavigationType || !_createRoutesFromChildren || !_matchRoutes) { + DEBUG_BUILD && + logger.warn(`reactRouterV6Instrumentation was unable to wrap Routes because of one or more missing parameters. + useEffect: ${_useEffect}. useLocation: ${_useLocation}. useNavigationType: ${_useNavigationType}. + createRoutesFromChildren: ${_createRoutesFromChildren}. matchRoutes: ${_matchRoutes}.`); + + return Routes; + } + + let isMountRenderPass: boolean = true; + + const SentryRoutes: React.FC
= (props: P) => {
+ const location = _useLocation();
+ const navigationType = _useNavigationType();
+
+ _useEffect(
+ () => {
+ const routes = _createRoutesFromChildren(props.children) as RouteObject[];
+
+ if (isMountRenderPass) {
+ updatePageloadTransaction(getActiveRootSpan(), location, routes);
+ isMountRenderPass = false;
+ } else {
+ handleNavigation(location, routes, navigationType, version);
+ }
+ },
+ // `props.children` is purposely not included in the dependency array, because we do not want to re-run this effect
+ // when the children change. We only want to start transactions when the location or navigation type change.
+ [location, navigationType],
+ );
+
+ // @ts-expect-error Setting more specific React Component typing for `R` generic above
+ // will break advanced type inference done by react router params
+ return , R extends React.FC >(Routes: R): R {
- if (!_useEffect || !_useLocation || !_useNavigationType || !_createRoutesFromChildren || !_matchRoutes) {
- DEBUG_BUILD &&
- logger.warn(`reactRouterV6Instrumentation was unable to wrap Routes because of one or more missing parameters.
- useEffect: ${_useEffect}. useLocation: ${_useLocation}. useNavigationType: ${_useNavigationType}.
- createRoutesFromChildren: ${_createRoutesFromChildren}. matchRoutes: ${_matchRoutes}.`);
-
- return Routes;
- }
-
- let isMountRenderPass: boolean = true;
-
- const SentryRoutes: React.FC = (props: P) => {
- const location = _useLocation();
- const navigationType = _useNavigationType();
-
- _useEffect(
- () => {
- const routes = _createRoutesFromChildren(props.children) as RouteObject[];
-
- if (isMountRenderPass) {
- updatePageloadTransaction(getActiveRootSpan(), location, routes);
- isMountRenderPass = false;
- } else {
- handleNavigation(location, routes, navigationType);
- }
- },
- // `props.children` is purposely not included in the dependency array, because we do not want to re-run this effect
- // when the children change. We only want to start transactions when the location or navigation type change.
- [location, navigationType],
- );
-
- // @ts-expect-error Setting more specific React Component typing for `R` generic above
- // will break advanced type inference done by react router params
- return , R extends React.FC >(routes: R): R {
+ return createV6CompatibleWithSentryReactRouterRouting (routes, '6');
}
diff --git a/packages/react/src/reactrouterv7.tsx b/packages/react/src/reactrouterv7.tsx
new file mode 100644
index 000000000000..2703a47d5428
--- /dev/null
+++ b/packages/react/src/reactrouterv7.tsx
@@ -0,0 +1,50 @@
+// React Router v7 uses the same integration as v6
+import type { browserTracingIntegration } from '@sentry/browser';
+
+import type { Integration } from '@sentry/core';
+import type { ReactRouterOptions } from './reactrouterv6-compat-utils';
+import {
+ createReactRouterV6CompatibleTracingIntegration,
+ createV6CompatibleWithSentryReactRouterRouting,
+ createV6CompatibleWrapCreateBrowserRouter,
+ createV6CompatibleWrapUseRoutes,
+} from './reactrouterv6-compat-utils';
+import type { CreateRouterFunction, Router, RouterState, UseRoutes } from './types';
+
+/**
+ * A browser tracing integration that uses React Router v7 to instrument navigations.
+ * Expects `useEffect`, `useLocation`, `useNavigationType`, `createRoutesFromChildren` and `matchRoutes` to be passed as options.
+ */
+export function reactRouterV7BrowserTracingIntegration(
+ options: Parameters , R extends React.FC >(routes: R): R {
+ return createV6CompatibleWithSentryReactRouterRouting (routes, '7');
+}
+
+/**
+ * A wrapper function that adds Sentry routing instrumentation to a React Router v7 createBrowserRouter function.
+ * This is used to automatically capture route changes as transactions when using the createBrowserRouter API.
+ */
+export function wrapCreateBrowserRouterV7<
+ TState extends RouterState = RouterState,
+ TRouter extends Router