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(); + }); +});