diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx
index 2200fcea97c3..925c1e6ab143 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx
@@ -8,7 +8,7 @@ Sentry.init({
// todo: get this from env
dsn: 'https://username@domain/123',
tunnel: `http://localhost:3031/`, // proxy server
- integrations: [Sentry.browserTracingIntegration()],
+ integrations: [Sentry.reactRouterTracingIntegration()],
tracesSampleRate: 1.0,
tracePropagationTargets: [/^\//],
});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts
index bb7472366681..1d8ab1b24a74 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts
@@ -12,6 +12,7 @@ export default [
]),
...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'),
]),
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx
index 9d55975e61a5..99086aadfeae 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx
@@ -1,3 +1,13 @@
+import { Link } from 'react-router';
+
export default function PerformancePage() {
- return
Performance Page
;
+ return (
+
+
Performance Page
+
+
+ );
}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/ssr.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/ssr.tsx
new file mode 100644
index 000000000000..253e964ff15d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/ssr.tsx
@@ -0,0 +1,7 @@
+export default function SsrPage() {
+ return (
+
+
SSR Page
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts
new file mode 100644
index 000000000000..57e3e764d6a8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.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('client - navigation performance', () => {
+ test('should create navigation transaction', async ({ page }) => {
+ const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === '/performance/ssr';
+ });
+
+ await page.goto(`/performance`); // pageload
+ await page.waitForTimeout(1000); // give it a sec before navigation
+ 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.source': 'url',
+ },
+ 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: 'url' },
+ 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 update navigation transaction for dynamic routes', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === '/performance/with/:param';
+ });
+
+ await page.goto(`/performance`); // pageload
+ await page.waitForTimeout(1000); // give it a sec before navigation
+ 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.source': 'route',
+ },
+ 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' },
+ });
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts
index c53494c723b4..e283ea522c4a 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts
@@ -53,6 +53,56 @@ test.describe('client - pageload performance', () => {
});
});
+ test('should update pageload transaction for dynamic routes', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === '/performance/with/:param';
+ });
+
+ 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.browser',
+ 'sentry.op': 'pageload',
+ 'sentry.source': 'route',
+ },
+ op: 'pageload',
+ origin: 'auto.pageload.browser',
+ },
+ },
+ 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' },
+ });
+ });
+
// todo: this page is currently not prerendered (see react-router.config.ts)
test('should send pageload transaction for prerendered pages', async ({ page }) => {
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
diff --git a/packages/react-router/src/client/hydratedRouter.ts b/packages/react-router/src/client/hydratedRouter.ts
new file mode 100644
index 000000000000..e5ec2d65d5ef
--- /dev/null
+++ b/packages/react-router/src/client/hydratedRouter.ts
@@ -0,0 +1,147 @@
+import { startBrowserTracingNavigationSpan } from '@sentry/browser';
+import type { Span } from '@sentry/core';
+import {
+ consoleSandbox,
+ getActiveSpan,
+ getClient,
+ getRootSpan,
+ GLOBAL_OBJ,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ spanToJSON,
+} from '@sentry/core';
+import type { DataRouter, RouterState } from 'react-router';
+import { DEBUG_BUILD } from '../common/debug-build';
+
+const GLOBAL_OBJ_WITH_DATA_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
+ __reactRouterDataRouter?: DataRouter;
+};
+
+const MAX_RETRIES = 40; // 2 seconds at 50ms interval
+
+/**
+ * Instruments the React Router Data Router for pageloads and navigation.
+ *
+ * This function waits for the router to be available after hydration, then:
+ * 1. Updates the pageload transaction with parameterized route info
+ * 2. Patches router.navigate() to create navigation transactions
+ * 3. Subscribes to router state changes to update navigation transactions with parameterized routes
+ */
+export function instrumentHydratedRouter(): void {
+ function trySubscribe(): boolean {
+ const router = GLOBAL_OBJ_WITH_DATA_ROUTER.__reactRouterDataRouter;
+
+ if (router) {
+ // The first time we hit the router, we try to update the pageload transaction
+ // todo: update pageload tx here
+ const pageloadSpan = getActiveRootSpan();
+ const pageloadName = pageloadSpan ? spanToJSON(pageloadSpan).description : undefined;
+ const parameterizePageloadRoute = getParameterizedRoute(router.state);
+ if (
+ pageloadName &&
+ normalizePathname(router.state.location.pathname) === normalizePathname(pageloadName) && // this event is for the currently active pageload
+ normalizePathname(parameterizePageloadRoute) !== normalizePathname(pageloadName) // route is not parameterized yet
+ ) {
+ pageloadSpan?.updateName(parameterizePageloadRoute);
+ pageloadSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
+ }
+
+ // Patching navigate for creating accurate navigation transactions
+ if (typeof router.navigate === 'function') {
+ const originalNav = router.navigate.bind(router);
+ router.navigate = function sentryPatchedNavigate(...args) {
+ maybeCreateNavigationTransaction(
+ String(args[0]) || '', // will be updated anyway
+ 'url', // this also will be updated once we have the parameterized route
+ );
+ return originalNav(...args);
+ };
+ }
+
+ // Subscribe to router state changes to update navigation transactions with parameterized routes
+ router.subscribe(newState => {
+ const navigationSpan = getActiveRootSpan();
+ const navigationSpanName = navigationSpan ? spanToJSON(navigationSpan).description : undefined;
+ const parameterizedNavRoute = getParameterizedRoute(newState);
+
+ if (
+ navigationSpanName && // we have an active pageload tx
+ newState.navigation.state === 'idle' && // navigation has completed
+ normalizePathname(newState.location.pathname) === normalizePathname(navigationSpanName) && // this event is for the currently active navigation
+ normalizePathname(parameterizedNavRoute) !== normalizePathname(navigationSpanName) // route is not parameterized yet
+ ) {
+ navigationSpan?.updateName(parameterizedNavRoute);
+ navigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
+ }
+ });
+ return true;
+ }
+ return false;
+ }
+
+ // Wait until the router is available (since the SDK loads before hydration)
+ if (!trySubscribe()) {
+ let retryCount = 0;
+ // Retry until the router is available or max retries reached
+ const interval = setInterval(() => {
+ if (trySubscribe() || retryCount >= MAX_RETRIES) {
+ if (retryCount >= MAX_RETRIES) {
+ DEBUG_BUILD &&
+ consoleSandbox(() => {
+ // eslint-disable-next-line no-console
+ console.warn('Unable to instrument React Router: router not found after hydration.');
+ });
+ }
+ clearInterval(interval);
+ }
+ retryCount++;
+ }, 50);
+ }
+}
+
+function maybeCreateNavigationTransaction(name: string, source: 'url' | 'route'): Span | undefined {
+ const client = getClient();
+
+ if (!client) {
+ return undefined;
+ }
+
+ return startBrowserTracingNavigationSpan(client, {
+ name,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react-router',
+ },
+ });
+}
+
+function getActiveRootSpan(): Span | undefined {
+ const activeSpan = getActiveSpan();
+ if (!activeSpan) {
+ return undefined;
+ }
+
+ const rootSpan = getRootSpan(activeSpan);
+
+ const op = spanToJSON(rootSpan).op;
+
+ // Only use this root span if it is a pageload or navigation span
+ return op === 'navigation' || op === 'pageload' ? rootSpan : undefined;
+}
+
+function getParameterizedRoute(routerState: RouterState): string {
+ const lastMatch = routerState.matches[routerState.matches.length - 1];
+ return normalizePathname(lastMatch?.route.path ?? routerState.location.pathname);
+}
+
+function normalizePathname(pathname: string): string {
+ // Ensure it starts with a single slash
+ let normalized = pathname.startsWith('/') ? pathname : `/${pathname}`;
+ // Remove trailing slash unless it's the root
+ if (normalized.length > 1 && normalized.endsWith('/')) {
+ normalized = normalized.slice(0, -1);
+ }
+ return normalized;
+}
diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts
index 8e25b84c4a0c..1bb86ec16deb 100644
--- a/packages/react-router/src/client/index.ts
+++ b/packages/react-router/src/client/index.ts
@@ -1,3 +1,4 @@
export * from '@sentry/browser';
export { init } from './sdk';
+export { reactRouterTracingIntegration } from './tracingIntegration';
diff --git a/packages/react-router/src/client/sdk.ts b/packages/react-router/src/client/sdk.ts
index 688a8ba460f1..a9b13f2e7cd0 100644
--- a/packages/react-router/src/client/sdk.ts
+++ b/packages/react-router/src/client/sdk.ts
@@ -1,19 +1,33 @@
import type { BrowserOptions } from '@sentry/browser';
import { init as browserInit } from '@sentry/browser';
import type { Client } from '@sentry/core';
-import { applySdkMetadata, setTag } from '@sentry/core';
+import { applySdkMetadata, consoleSandbox, setTag } from '@sentry/core';
+
+const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing';
/**
* Initializes the client side of the React Router SDK.
*/
export function init(options: BrowserOptions): Client | undefined {
- const opts = {
- ...options,
- };
+ // If BrowserTracing integration was passed to options, emit a warning
+ if (options.integrations && Array.isArray(options.integrations)) {
+ const hasBrowserTracing = options.integrations.some(
+ integration => integration.name === BROWSER_TRACING_INTEGRATION_ID,
+ );
+
+ if (hasBrowserTracing) {
+ consoleSandbox(() => {
+ // eslint-disable-next-line no-console
+ console.warn(
+ 'browserTracingIntegration is not fully compatible with @sentry/react-router. Please use reactRouterTracingIntegration instead.',
+ );
+ });
+ }
+ }
- applySdkMetadata(opts, 'react-router', ['react-router', 'browser']);
+ applySdkMetadata(options, 'react-router', ['react-router', 'browser']);
- const client = browserInit(opts);
+ const client = browserInit(options);
setTag('runtime', 'browser');
diff --git a/packages/react-router/src/client/tracingIntegration.ts b/packages/react-router/src/client/tracingIntegration.ts
new file mode 100644
index 000000000000..01b71f36d92a
--- /dev/null
+++ b/packages/react-router/src/client/tracingIntegration.ts
@@ -0,0 +1,23 @@
+import { browserTracingIntegration as originalBrowserTracingIntegration } from '@sentry/browser';
+import type { Integration } from '@sentry/core';
+import { instrumentHydratedRouter } from './hydratedRouter';
+
+/**
+ * Browser tracing integration for React Router (Framework) applications.
+ * This integration will create navigation spans and enhance transactions names with parameterized routes.
+ */
+export function reactRouterTracingIntegration(): Integration {
+ const browserTracingIntegrationInstance = originalBrowserTracingIntegration({
+ // Navigation transactions are started within the hydrated router instrumentation
+ instrumentNavigation: false,
+ });
+
+ return {
+ ...browserTracingIntegrationInstance,
+ name: 'ReactRouterTracingIntegration',
+ afterAllSetup(client) {
+ browserTracingIntegrationInstance.afterAllSetup(client);
+ instrumentHydratedRouter();
+ },
+ };
+}
diff --git a/packages/react-router/test/client/hydratedRouter.test.ts b/packages/react-router/test/client/hydratedRouter.test.ts
new file mode 100644
index 000000000000..98ed1a241d93
--- /dev/null
+++ b/packages/react-router/test/client/hydratedRouter.test.ts
@@ -0,0 +1,111 @@
+import * as browser from '@sentry/browser';
+import * as core from '@sentry/core';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { instrumentHydratedRouter } from '../../src/client/hydratedRouter';
+
+vi.mock('@sentry/core', async () => {
+ const actual = await vi.importActual('@sentry/core');
+ return {
+ ...actual,
+ getActiveSpan: vi.fn(),
+ getRootSpan: vi.fn(),
+ spanToJSON: vi.fn(),
+ getClient: vi.fn(),
+ SEMANTIC_ATTRIBUTE_SENTRY_OP: 'op',
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'origin',
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'source',
+ GLOBAL_OBJ: globalThis,
+ };
+});
+vi.mock('@sentry/browser', () => ({
+ startBrowserTracingNavigationSpan: vi.fn(),
+}));
+
+describe('instrumentHydratedRouter', () => {
+ let originalRouter: any;
+ let mockRouter: any;
+ let mockPageloadSpan: any;
+ let mockNavigationSpan: any;
+
+ beforeEach(() => {
+ originalRouter = (globalThis as any).__reactRouterDataRouter;
+ mockRouter = {
+ state: {
+ location: { pathname: '/foo/bar' },
+ matches: [{ route: { path: '/foo/:id' } }],
+ },
+ navigate: vi.fn(),
+ subscribe: vi.fn(),
+ };
+ (globalThis as any).__reactRouterDataRouter = mockRouter;
+
+ mockPageloadSpan = { updateName: vi.fn(), setAttribute: vi.fn() };
+ mockNavigationSpan = { updateName: vi.fn(), setAttribute: vi.fn() };
+
+ (core.getActiveSpan as any).mockReturnValue(mockPageloadSpan);
+ (core.getRootSpan as any).mockImplementation((span: any) => span);
+ (core.spanToJSON as any).mockImplementation((_span: any) => ({
+ description: '/foo/bar',
+ op: 'pageload',
+ }));
+ (core.getClient as any).mockReturnValue({});
+ (browser.startBrowserTracingNavigationSpan as any).mockReturnValue(mockNavigationSpan);
+ });
+
+ afterEach(() => {
+ (globalThis as any).__reactRouterDataRouter = originalRouter;
+ vi.clearAllMocks();
+ });
+
+ it('subscribes to the router and patches navigate', () => {
+ instrumentHydratedRouter();
+ expect(typeof mockRouter.navigate).toBe('function');
+ expect(mockRouter.subscribe).toHaveBeenCalled();
+ });
+
+ it('updates pageload transaction name if needed', () => {
+ instrumentHydratedRouter();
+ expect(mockPageloadSpan.updateName).toHaveBeenCalled();
+ expect(mockPageloadSpan.setAttribute).toHaveBeenCalled();
+ });
+
+ it('creates navigation transaction on navigate', () => {
+ instrumentHydratedRouter();
+ mockRouter.navigate('/bar');
+ expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalled();
+ });
+
+ it('updates navigation transaction on state change to idle', () => {
+ instrumentHydratedRouter();
+ // Simulate a state change to idle
+ const callback = mockRouter.subscribe.mock.calls[0][0];
+ const newState = {
+ location: { pathname: '/foo/bar' },
+ matches: [{ route: { path: '/foo/:id' } }],
+ navigation: { state: 'idle' },
+ };
+ mockRouter.navigate('/foo/bar');
+ // After navigation, the active span should be the navigation span
+ (core.getActiveSpan as any).mockReturnValue(mockNavigationSpan);
+ callback(newState);
+ expect(mockNavigationSpan.updateName).toHaveBeenCalled();
+ expect(mockNavigationSpan.setAttribute).toHaveBeenCalled();
+ });
+
+ it('does not update navigation transaction on state change to loading', () => {
+ instrumentHydratedRouter();
+ // Simulate a state change to loading (non-idle)
+ const callback = mockRouter.subscribe.mock.calls[0][0];
+ const newState = {
+ location: { pathname: '/foo/bar' },
+ matches: [{ route: { path: '/foo/:id' } }],
+ navigation: { state: 'loading' },
+ };
+ mockRouter.navigate('/foo/bar');
+ // After navigation, the active span should be the navigation span
+ (core.getActiveSpan as any).mockReturnValue(mockNavigationSpan);
+ callback(newState);
+ expect(mockNavigationSpan.updateName).not.toHaveBeenCalled();
+ expect(mockNavigationSpan.setAttribute).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/react-router/test/client/sdk.test.ts b/packages/react-router/test/client/sdk.test.ts
index 118ddd775217..d6767ccfff23 100644
--- a/packages/react-router/test/client/sdk.test.ts
+++ b/packages/react-router/test/client/sdk.test.ts
@@ -1,9 +1,12 @@
import * as SentryBrowser from '@sentry/browser';
import { getCurrentScope, getGlobalScope, getIsolationScope, SDK_VERSION } from '@sentry/browser';
+import * as SentryCore from '@sentry/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { init as reactRouterInit } from '../../src/client';
const browserInit = vi.spyOn(SentryBrowser, 'init');
+const setTag = vi.spyOn(SentryCore, 'setTag');
+const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
describe('React Router client SDK', () => {
describe('init', () => {
@@ -41,5 +44,20 @@ describe('React Router client SDK', () => {
it('returns client from init', () => {
expect(reactRouterInit({})).not.toBeUndefined();
});
+
+ it('sets the runtime tag to browser', () => {
+ reactRouterInit({});
+ expect(setTag).toHaveBeenCalledWith('runtime', 'browser');
+ });
+
+ it('warns if BrowserTracing integration is present', () => {
+ reactRouterInit({
+ integrations: [{ name: 'BrowserTracing' }],
+ });
+
+ expect(consoleWarn).toHaveBeenCalledWith(
+ 'browserTracingIntegration is not fully compatible with @sentry/react-router. Please use reactRouterTracingIntegration instead.',
+ );
+ });
});
});
diff --git a/packages/react-router/test/client/tracingIntegration.test.ts b/packages/react-router/test/client/tracingIntegration.test.ts
new file mode 100644
index 000000000000..9d511b4c6bde
--- /dev/null
+++ b/packages/react-router/test/client/tracingIntegration.test.ts
@@ -0,0 +1,30 @@
+import * as sentryBrowser from '@sentry/browser';
+import type { Client } from '@sentry/core';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import * as hydratedRouterModule from '../../src/client/hydratedRouter';
+import { reactRouterTracingIntegration } from '../../src/client/tracingIntegration';
+
+describe('reactRouterTracingIntegration', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns an integration with the correct name and properties', () => {
+ const integration = reactRouterTracingIntegration();
+ expect(integration.name).toBe('ReactRouterTracingIntegration');
+ expect(typeof integration.afterAllSetup).toBe('function');
+ });
+
+ it('calls instrumentHydratedRouter and browserTracingIntegrationInstance.afterAllSetup in afterAllSetup', () => {
+ const browserTracingSpy = vi.spyOn(sentryBrowser, 'browserTracingIntegration').mockImplementation(() => ({
+ afterAllSetup: vi.fn(),
+ name: 'BrowserTracing',
+ }));
+ const instrumentSpy = vi.spyOn(hydratedRouterModule, 'instrumentHydratedRouter').mockImplementation(() => null);
+ const integration = reactRouterTracingIntegration();
+ integration.afterAllSetup?.({} as Client);
+
+ expect(browserTracingSpy).toHaveBeenCalled();
+ expect(instrumentSpy).toHaveBeenCalled();
+ });
+});