From 011b6baab07ccbb50e3df830cb68eb74f46a3944 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 18 Dec 2025 13:46:39 +0000 Subject: [PATCH 1/3] feat(react-router): Add support for React Router instrumentation API --- .../.gitignore | 32 ++ .../.npmrc | 2 + .../app/app.css | 5 + .../app/entry.client.tsx | 33 ++ .../app/entry.server.tsx | 22 + .../app/root.tsx | 69 +++ .../app/routes.ts | 16 + .../app/routes/home.tsx | 12 + .../app/routes/performance/dynamic-param.tsx | 16 + .../app/routes/performance/error-loader.tsx | 12 + .../app/routes/performance/index.tsx | 20 + .../app/routes/performance/lazy-route.tsx | 14 + .../app/routes/performance/server-action.tsx | 22 + .../app/routes/performance/server-loader.tsx | 16 + .../app/routes/performance/ssr.tsx | 7 + .../app/routes/performance/static.tsx | 7 + .../routes/performance/with-middleware.tsx | 30 ++ .../instrument.mjs | 10 + .../package.json | 61 +++ .../playwright.config.mjs | 8 + .../react-router.config.ts | 9 + .../start-event-proxy.mjs | 6 + .../tests/constants.ts | 1 + .../tests/errors/errors.server.test.ts | 93 ++++ .../tests/performance/lazy.server.test.ts | 115 +++++ .../performance/middleware.server.test.ts | 85 ++++ .../performance/navigation.client.test.ts | 149 ++++++ .../tests/performance/pageload.client.test.ts | 27 ++ .../performance/performance.server.test.ts | 154 ++++++ .../tsconfig.json | 20 + .../vite.config.ts | 6 + .../src/client/createClientInstrumentation.ts | 201 ++++++++ .../react-router/src/client/hydratedRouter.ts | 35 +- packages/react-router/src/client/index.ts | 14 +- .../src/client/tracingIntegration.ts | 58 ++- packages/react-router/src/common/types.ts | 96 ++++ packages/react-router/src/common/utils.ts | 72 +++ .../src/server/createServerInstrumentation.ts | 240 ++++++++++ packages/react-router/src/server/index.ts | 7 + .../src/server/instrumentation/reactRouter.ts | 8 + .../server/integration/reactRouterServer.ts | 22 +- .../src/server/wrapSentryHandleRequest.ts | 31 +- .../src/server/wrapServerAction.ts | 25 +- .../src/server/wrapServerLoader.ts | 25 +- .../createClientInstrumentation.test.ts | 451 ++++++++++++++++++ .../test/client/hydratedRouter.test.ts | 47 ++ .../test/client/tracingIntegration.test.ts | 97 ++++ .../react-router/test/common/utils.test.ts | 144 ++++++ .../createServerInstrumentation.test.ts | 432 +++++++++++++++++ .../instrumentation/reactRouterServer.test.ts | 1 + .../server/wrapSentryHandleRequest.test.ts | 38 ++ .../test/server/wrapServerAction.test.ts | 43 +- .../test/server/wrapServerLoader.test.ts | 43 +- 53 files changed, 3168 insertions(+), 41 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/app.css create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/home.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/dynamic-param.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/error-loader.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/lazy-route.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/server-action.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/server-loader.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/ssr.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/static.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/with-middleware.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/react-router.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/constants.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/errors/errors.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/lazy.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/middleware.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/pageload.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/performance.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/vite.config.ts create mode 100644 packages/react-router/src/client/createClientInstrumentation.ts create mode 100644 packages/react-router/src/common/types.ts create mode 100644 packages/react-router/src/common/utils.ts create mode 100644 packages/react-router/src/server/createServerInstrumentation.ts create mode 100644 packages/react-router/test/client/createClientInstrumentation.test.ts create mode 100644 packages/react-router/test/common/utils.test.ts create mode 100644 packages/react-router/test/server/createServerInstrumentation.test.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/.gitignore new file mode 100644 index 000000000000..ebb991370034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/.gitignore @@ -0,0 +1,32 @@ +# 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 + +# react router +.react-router diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/.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-framework-instrumentation/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/app.css new file mode 100644 index 000000000000..e78d2096ad20 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/app.css @@ -0,0 +1,5 @@ +body { + font-family: system-ui, sans-serif; + margin: 0; + padding: 20px; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx new file mode 100644 index 000000000000..c8bd9df2ba99 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/react-router'; +import { StrictMode, startTransition } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { HydratedRouter } from 'react-router/dom'; + +// Create the tracing integration with useInstrumentationAPI enabled +// This must be set BEFORE Sentry.init() to prepare the instrumentation +const tracing = Sentry.reactRouterTracingIntegration({ useInstrumentationAPI: true }); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: 'https://username@domain/123', + tunnel: `http://localhost:3031/`, // proxy server + integrations: [tracing], + tracesSampleRate: 1.0, + tracePropagationTargets: [/^\//], +}); + +// Get the client instrumentation from the Sentry integration +// NOTE: As of React Router 7.x, HydratedRouter does NOT invoke these hooks in Framework Mode. +// The client-side instrumentation is prepared for when React Router adds support. +// Client-side navigation is currently handled by the legacy instrumentHydratedRouter() approach. +const sentryClientInstrumentation = [tracing.clientInstrumentation]; + +startTransition(() => { + hydrateRoot( + document, + + {/* unstable_instrumentations is React Router 7.x's prop name (will become `instrumentations` in v8) */} + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx new file mode 100644 index 000000000000..1cbc6b6166fe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.server.tsx @@ -0,0 +1,22 @@ +import { createReadableStreamFromReadable } from '@react-router/node'; +import * as Sentry from '@sentry/react-router'; +import { renderToPipeableStream } from 'react-dom/server'; +import { ServerRouter } from 'react-router'; +import { type HandleErrorFunction } from 'react-router'; + +const ABORT_DELAY = 5_000; + +const handleRequest = Sentry.createSentryHandleRequest({ + streamTimeout: ABORT_DELAY, + ServerRouter, + renderToPipeableStream, + createReadableStreamFromReadable, +}); + +export default handleRequest; + +export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true }); + +// Use Sentry's instrumentation API for server-side tracing +// `unstable_instrumentations` is React Router 7.x's export name (will become `instrumentations` in v8) +export const unstable_instrumentations = [Sentry.createSentryServerInstrumentation()]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/root.tsx new file mode 100644 index 000000000000..227c08f7730c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/root.tsx @@ -0,0 +1,69 @@ +import * as Sentry from '@sentry/react-router'; +import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router'; +import type { Route } from './+types/root'; +import stylesheet from './app.css?url'; + +export const links: Route.LinksFunction = () => [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', + }, + { rel: 'stylesheet', href: stylesheet }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!'; + let details = 'An unexpected error occurred.'; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error'; + details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; + } else if (error && error instanceof Error) { + Sentry.captureException(error); + if (import.meta.env.DEV) { + details = error.message; + stack = error.stack; + } + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts new file mode 100644 index 000000000000..af261d6db1eb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes.ts @@ -0,0 +1,16 @@ +import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes'; + +export default [ + index('routes/home.tsx'), + ...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'), + route('server-loader', 'routes/performance/server-loader.tsx'), + route('server-action', 'routes/performance/server-action.tsx'), + route('with-middleware', 'routes/performance/with-middleware.tsx'), + route('error-loader', 'routes/performance/error-loader.tsx'), + route('lazy-route', 'routes/performance/lazy-route.tsx'), + ]), +] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/home.tsx new file mode 100644 index 000000000000..d061ad54030b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/home.tsx @@ -0,0 +1,12 @@ +import type { Route } from './+types/home'; + +export function meta({}: Route.MetaArgs) { + return [ + { title: 'React Router Instrumentation API Test' }, + { name: 'description', content: 'Testing React Router instrumentation API' }, + ]; +} + +export default function Home() { + return
home
; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/dynamic-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/dynamic-param.tsx new file mode 100644 index 000000000000..bff0410f849c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/dynamic-param.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/dynamic-param'; + +// Minimal loader to trigger Sentry's route instrumentation +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function loader() { + return null; +} + +export default function DynamicParamPage({ params }: Route.ComponentProps) { + return ( +
+

Dynamic Param Page

+
Param: {params.param}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/error-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/error-loader.tsx new file mode 100644 index 000000000000..6dd3d3013f37 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/error-loader.tsx @@ -0,0 +1,12 @@ +export function loader(): never { + throw new Error('Loader error for testing'); +} + +export default function ErrorLoaderPage() { + return ( +
+

Error Loader Page

+

This should not render

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/index.tsx new file mode 100644 index 000000000000..901de267cb3e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/index.tsx @@ -0,0 +1,20 @@ +import { Link } from 'react-router'; + +// Minimal loader to trigger Sentry's route instrumentation +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function loader() { + return null; +} + +export default function PerformancePage() { + return ( +
+

Performance Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/lazy-route.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/lazy-route.tsx new file mode 100644 index 000000000000..9ea3102f6e3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/lazy-route.tsx @@ -0,0 +1,14 @@ +export async function loader() { + // Simulate a slow lazy load + await new Promise(resolve => setTimeout(resolve, 100)); + return { message: 'Lazy loader data' }; +} + +export default function LazyRoute() { + return ( +
+

Lazy Route

+

This route was lazily loaded

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/server-action.tsx new file mode 100644 index 000000000000..4b5ad7a4f5ac --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/server-action.tsx @@ -0,0 +1,22 @@ +import { Form } from 'react-router'; +import type { Route } from './+types/server-action'; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData(); + const name = formData.get('name')?.toString() || ''; + await new Promise(resolve => setTimeout(resolve, 100)); + return { success: true, name }; +} + +export default function ServerActionPage({ actionData }: Route.ComponentProps) { + return ( +
+

Server Action Page

+
+ + +
+ {actionData?.success &&
Action completed for: {actionData.name}
} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/server-loader.tsx new file mode 100644 index 000000000000..3ab65bff8ecf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/server-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export async function loader() { + await new Promise(resolve => setTimeout(resolve, 100)); + return { data: 'burritos' }; +} + +export default function ServerLoaderPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Server Loader Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/ssr.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/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-instrumentation/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-instrumentation/app/routes/performance/static.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/static.tsx new file mode 100644 index 000000000000..773f6e64ebea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/static.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+

Static Page

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/with-middleware.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/with-middleware.tsx new file mode 100644 index 000000000000..ed4f4713d7b6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/routes/performance/with-middleware.tsx @@ -0,0 +1,30 @@ +import type { Route } from './+types/with-middleware'; + +// Middleware runs before loaders/actions on matching routes +// With future.v8_middleware enabled, we export 'middleware' (not 'unstable_middleware') +export const middleware: Route.MiddlewareFunction[] = [ + async function authMiddleware({ context }, next) { + // Code runs BEFORE handlers + // Type assertion to allow setting custom properties on context + (context as any).middlewareCalled = true; + + // Must call next() and return the response + const response = await next(); + + // Code runs AFTER handlers (can modify response headers here) + return response; + }, +]; + +export function loader() { + return { message: 'Middleware route loaded' }; +} + +export default function WithMiddlewarePage() { + return ( +
+

Middleware Route

+

This route has middleware

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs new file mode 100644 index 000000000000..bb1dad2e5da9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/react-router'; + +// Initialize Sentry early (before the server starts) +// The server instrumentations are created in entry.server.tsx +Sentry.init({ + dsn: 'https://username@domain/123', + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, + tunnel: `http://localhost:3031/`, // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json new file mode 100644 index 000000000000..9666bf218893 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/package.json @@ -0,0 +1,61 @@ +{ + "name": "react-router-7-framework-instrumentation", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "@react-router/node": "latest", + "@react-router/serve": "latest", + "@sentry/react-router": "latest || *", + "isbot": "^5.1.17", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "latest" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@react-router/dev": "latest", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "typescript": "^5.6.3", + "vite": "^5.4.11" + }, + "scripts": { + "build": "react-router build", + "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev", + "start": "NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js", + "proxy": "node start-event-proxy.mjs", + "typecheck": "react-router typegen && tsc", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:ts && pnpm test:playwright", + "test:ts": "pnpm typecheck", + "test:playwright": "playwright 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" + ] + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/playwright.config.mjs new file mode 100644 index 000000000000..3ed5721107a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `PORT=3030 pnpm start`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/react-router.config.ts new file mode 100644 index 000000000000..72f2eef3b0f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/react-router.config.ts @@ -0,0 +1,9 @@ +import type { Config } from '@react-router/dev/config'; + +export default { + ssr: true, + prerender: ['/performance/static'], + future: { + v8_middleware: true, + }, +} satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/start-event-proxy.mjs new file mode 100644 index 000000000000..f70c1d3f20f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-framework-instrumentation', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/constants.ts new file mode 100644 index 000000000000..850613659daa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/constants.ts @@ -0,0 +1 @@ +export const APP_NAME = 'react-router-7-framework-instrumentation'; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/errors/errors.server.test.ts new file mode 100644 index 000000000000..85d79563d637 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/errors/errors.server.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server - instrumentation API error capture', () => { + test('should capture loader errors with instrumentation API mechanism', async ({ page }) => { + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'Loader error for testing'; + }); + + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/error-loader'; + }); + + await page.goto(`/performance/error-loader`).catch(() => { + // Expected to fail due to loader error + }); + + const [error, transaction] = await Promise.all([errorPromise, txPromise]); + + // Verify the error was captured with correct mechanism and transaction name + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Loader error for testing', + mechanism: { + type: 'react_router.loader', + handled: false, + }, + }, + ], + }, + transaction: 'GET /performance/error-loader', + }); + + // Verify the transaction was also created with correct attributes + expect(transaction).toMatchObject({ + transaction: 'GET /performance/error-loader', + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.react_router.instrumentation_api', + }, + }, + }); + }); + + test('should include loader span in transaction even when loader throws', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/error-loader'; + }); + + await page.goto(`/performance/error-loader`).catch(() => { + // Expected to fail due to loader error + }); + + const transaction = await txPromise; + + // Find the loader span + const loaderSpan = transaction?.spans?.find( + (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react-router.loader', + ); + + expect(loaderSpan).toMatchObject({ + data: { + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + 'sentry.op': 'function.react-router.loader', + }, + op: 'function.react-router.loader', + }); + }); + + test('error and transaction should share the same trace', async ({ page }) => { + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'Loader error for testing'; + }); + + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/error-loader'; + }); + + await page.goto(`/performance/error-loader`).catch(() => { + // Expected to fail due to loader error + }); + + const [error, transaction] = await Promise.all([errorPromise, txPromise]); + + // Error and transaction should have the same trace_id + expect(error.contexts?.trace?.trace_id).toBe(transaction.contexts?.trace?.trace_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/lazy.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/lazy.server.test.ts new file mode 100644 index 000000000000..33ff1021d0c8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/lazy.server.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +// Known React Router limitation: route.lazy hooks only work in Data Mode (createBrowserRouter). +// Framework Mode uses bundler code-splitting which doesn't trigger the lazy hook. +// See: https://github.com/remix-run/react-router/blob/main/decisions/0002-lazy-route-modules.md +// Using test.fail() to auto-detect when React Router fixes this upstream. +test.describe('server - instrumentation API lazy loading', () => { + test.fail('should instrument lazy route loading with instrumentation API origin', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/lazy-route'; + }); + + await page.goto(`/performance/lazy-route`); + + const transaction = await txPromise; + + // Verify the lazy route content is rendered + await expect(page.locator('#lazy-route-title')).toBeVisible(); + await expect(page.locator('#lazy-route-content')).toHaveText('This route was lazily loaded'); + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.instrumentation_api', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.instrumentation_api', + }, + }, + spans: expect.any(Array), + transaction: 'GET /performance/lazy-route', + type: 'transaction', + transaction_info: { source: 'route' }, + }); + + // Find the lazy span + const lazySpan = transaction?.spans?.find( + (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react-router.lazy', + ); + + expect(lazySpan).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + 'sentry.op': 'function.react-router.lazy', + }, + description: 'Lazy Route Load', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + op: 'function.react-router.lazy', + origin: 'auto.function.react_router.instrumentation_api', + }); + }); + + test('should include loader span after lazy loading completes', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/lazy-route'; + }); + + await page.goto(`/performance/lazy-route`); + + const transaction = await txPromise; + + // Find the loader span that runs after lazy loading + const loaderSpan = transaction?.spans?.find( + (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react-router.loader', + ); + + expect(loaderSpan).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + 'sentry.op': 'function.react-router.loader', + }, + description: '/performance/lazy-route', + op: 'function.react-router.loader', + origin: 'auto.function.react_router.instrumentation_api', + }); + }); + + test.fail('should have correct span ordering: lazy before loader', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/lazy-route'; + }); + + await page.goto(`/performance/lazy-route`); + + const transaction = await txPromise; + + const lazySpan = transaction?.spans?.find( + (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react-router.lazy', + ); + + const loaderSpan = transaction?.spans?.find( + (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react-router.loader', + ); + + expect(lazySpan).toBeDefined(); + expect(loaderSpan).toBeDefined(); + + // Lazy span should start before or at the same time as loader + // (lazy loading must complete before loader can run) + expect(lazySpan!.start_timestamp).toBeLessThanOrEqual(loaderSpan!.start_timestamp); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/middleware.server.test.ts new file mode 100644 index 000000000000..08ee2b9cda0c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/middleware.server.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +// Note: React Router middleware instrumentation now works in Framework Mode. +// Previously this was a known limitation (see: https://github.com/remix-run/react-router/discussions/12950) +test.describe('server - instrumentation API middleware', () => { + test('should instrument server middleware with instrumentation API origin', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with-middleware'; + }); + + await page.goto(`/performance/with-middleware`); + + const transaction = await txPromise; + + // Verify the middleware route content is rendered + await expect(page.locator('#middleware-route-title')).toBeVisible(); + await expect(page.locator('#middleware-route-content')).toHaveText('This route has middleware'); + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.instrumentation_api', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.instrumentation_api', + }, + }, + spans: expect.any(Array), + transaction: 'GET /performance/with-middleware', + type: 'transaction', + transaction_info: { source: 'route' }, + }); + + // Find the middleware span + const middlewareSpan = transaction?.spans?.find( + (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react-router.middleware', + ); + + expect(middlewareSpan).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + 'sentry.op': 'function.react-router.middleware', + }, + description: '/performance/with-middleware', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + op: 'function.react-router.middleware', + origin: 'auto.function.react_router.instrumentation_api', + }); + }); + + test('should have middleware span run before loader span', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with-middleware'; + }); + + await page.goto(`/performance/with-middleware`); + + const transaction = await txPromise; + + const middlewareSpan = transaction?.spans?.find( + (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react-router.middleware', + ); + + const loaderSpan = transaction?.spans?.find( + (span: { data?: { 'sentry.op'?: string } }) => span.data?.['sentry.op'] === 'function.react-router.loader', + ); + + expect(middlewareSpan).toBeDefined(); + expect(loaderSpan).toBeDefined(); + + // Middleware should start before loader + expect(middlewareSpan!.start_timestamp).toBeLessThanOrEqual(loaderSpan!.start_timestamp); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts new file mode 100644 index 000000000000..b6a7fc8b20ac --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/navigation.client.test.ts @@ -0,0 +1,149 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +// Known React Router limitation: HydratedRouter doesn't invoke instrumentation API +// hooks on the client-side in Framework Mode. Server-side instrumentation works. +// See: https://github.com/remix-run/react-router/discussions/13749 +// The legacy HydratedRouter instrumentation provides fallback navigation tracking. + +test.describe('client - navigation fallback to legacy instrumentation', () => { + test('should send navigation transaction via legacy HydratedRouter instrumentation', async ({ page }) => { + // First load the performance page + await page.goto(`/performance`); + await page.waitForTimeout(1000); + + // Wait for the navigation transaction (from legacy instrumentation) + const navigationTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + // Click on the SSR link to navigate + await page.getByRole('link', { name: 'SSR Page' }).click(); + + const transaction = await navigationTxPromise; + + // Navigation should work via legacy HydratedRouter instrumentation + // (not instrumentation_api since that doesn't work in Framework Mode) + expect(transaction).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react_router', // Legacy origin, not instrumentation_api + }, + }, + transaction: '/performance/ssr', + type: 'transaction', + }); + }); + + test('should parameterize navigation transaction for dynamic routes', async ({ page }) => { + await page.goto(`/performance`); + await page.waitForTimeout(1000); + + const navigationTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + await page.getByRole('link', { name: 'With Param Page' }).click(); + + const transaction = await navigationTxPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react_router', + data: { + 'sentry.source': 'route', + }, + }, + }, + transaction: '/performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + }); + }); +}); + +// Tests for instrumentation API navigation - expected to fail until React Router fixes upstream +test.describe('client - instrumentation API navigation (upstream limitation)', () => { + test.fixme('should send navigation transaction with instrumentation API origin', async ({ page }) => { + // First load the performance page + await page.goto(`/performance`); + + // Wait for the navigation transaction + const navigationTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && + transactionEvent.contexts?.trace?.data?.['sentry.origin'] === 'auto.navigation.react_router.instrumentation_api' + ); + }); + + // Click on the SSR link to navigate + await page.getByRole('link', { name: 'SSR Page' }).click(); + + const transaction = await navigationTxPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'navigation', + 'sentry.origin': 'auto.navigation.react_router.instrumentation_api', + 'sentry.source': 'url', + }, + op: 'navigation', + origin: 'auto.navigation.react_router.instrumentation_api', + }, + }, + transaction: '/performance/ssr', + type: 'transaction', + transaction_info: { source: 'url' }, + }); + }); + + test.fixme('should send navigation transaction on parameterized route', async ({ page }) => { + // First load the performance page + await page.goto(`/performance`); + + // Wait for the navigation transaction + const navigationTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/with/sentry' && + transactionEvent.contexts?.trace?.data?.['sentry.origin'] === 'auto.navigation.react_router.instrumentation_api' + ); + }); + + // Click on the With Param link to navigate + await page.getByRole('link', { name: 'With Param Page' }).click(); + + const transaction = await navigationTxPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'navigation', + 'sentry.origin': 'auto.navigation.react_router.instrumentation_api', + 'sentry.source': 'url', + }, + op: 'navigation', + origin: 'auto.navigation.react_router.instrumentation_api', + }, + }, + transaction: '/performance/with/sentry', + type: 'transaction', + transaction_info: { source: 'url' }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/pageload.client.test.ts new file mode 100644 index 000000000000..4c8c04e2cb66 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/pageload.client.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client - instrumentation API pageload', () => { + test('should send pageload transaction', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'pageload', + }, + }, + transaction: '/performance', + type: 'transaction', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/performance.server.test.ts new file mode 100644 index 000000000000..c0803eed46d8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tests/performance/performance.server.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server - instrumentation API performance', () => { + test('should send server transaction on pageload with instrumentation API origin', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance'; + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.instrumentation_api', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.instrumentation_api', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /performance', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'node', + request: { + url: expect.stringContaining('/performance'), + 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/node', version: expect.any(String) }, + ], + }, + tags: { + runtime: 'node', + }, + }); + }); + + test('should send server transaction on parameterized route with instrumentation API origin', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with/:param'; + }); + + await page.goto(`/performance/with/some-param`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.instrumentation_api', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.instrumentation_api', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'node', + request: { + url: expect.stringContaining('/performance/with/some-param'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + }); + }); + + test('should instrument server loader with instrumentation API origin', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/server-loader'; + }); + + await page.goto(`/performance/server-loader`); + + const transaction = await txPromise; + + // Find the loader span + const loaderSpan = transaction?.spans?.find(span => span.data?.['sentry.op'] === 'function.react-router.loader'); + + expect(loaderSpan).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + 'sentry.op': 'function.react-router.loader', + }, + description: '/performance/server-loader', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'function.react-router.loader', + origin: 'auto.function.react_router.instrumentation_api', + }); + }); + + test('should instrument server action with instrumentation API origin', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'POST /performance/server-action'; + }); + + await page.goto(`/performance/server-action`); + await page.getByRole('button', { name: 'Submit' }).click(); + + const transaction = await txPromise; + + // Find the action span + const actionSpan = transaction?.spans?.find(span => span.data?.['sentry.op'] === 'function.react-router.action'); + + expect(actionSpan).toMatchObject({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + 'sentry.op': 'function.react-router.action', + }, + description: '/performance/server-action', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'function.react-router.action', + origin: 'auto.function.react_router.instrumentation_api', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tsconfig.json new file mode 100644 index 000000000000..a16df276e8bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + }, + "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/vite.config.ts new file mode 100644 index 000000000000..68ba30d69397 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/vite.config.ts @@ -0,0 +1,6 @@ +import { reactRouter } from '@react-router/dev/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [reactRouter()], +}); diff --git a/packages/react-router/src/client/createClientInstrumentation.ts b/packages/react-router/src/client/createClientInstrumentation.ts new file mode 100644 index 000000000000..1c3a32ffbab9 --- /dev/null +++ b/packages/react-router/src/client/createClientInstrumentation.ts @@ -0,0 +1,201 @@ +import { startBrowserTracingNavigationSpan } from '@sentry/browser'; +import { + debug, + getClient, + GLOBAL_OBJ, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + startSpan, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../common/debug-build'; +import type { ClientInstrumentation, InstrumentableRoute, InstrumentableRouter } from '../common/types'; +import { captureInstrumentationError, getPathFromRequest, getPattern, normalizeRoutePath } from '../common/utils'; + +const SENTRY_CLIENT_INSTRUMENTATION_FLAG = '__sentryReactRouterClientInstrumentationUsed'; +const SENTRY_NAVIGATE_HOOK_INVOKED_FLAG = '__sentryReactRouterNavigateHookInvoked'; + +type GlobalObjWithFlags = typeof GLOBAL_OBJ & { + [SENTRY_CLIENT_INSTRUMENTATION_FLAG]?: boolean; + [SENTRY_NAVIGATE_HOOK_INVOKED_FLAG]?: boolean; +}; + +/** + * Options for creating Sentry client instrumentation. + */ +export interface CreateSentryClientInstrumentationOptions { + /** + * Whether to capture errors from loaders/actions automatically. + * @default true + */ + captureErrors?: boolean; +} + +/** + * Creates a Sentry client instrumentation for React Router's instrumentation API. + * @experimental + */ +export function createSentryClientInstrumentation( + options: CreateSentryClientInstrumentationOptions = {}, +): ClientInstrumentation { + const { captureErrors = true } = options; + + (GLOBAL_OBJ as GlobalObjWithFlags)[SENTRY_CLIENT_INSTRUMENTATION_FLAG] = true; + DEBUG_BUILD && debug.log('React Router client instrumentation API enabled.'); + + return { + router(router: InstrumentableRouter) { + router.instrument({ + async navigate(callNavigate, info) { + (GLOBAL_OBJ as GlobalObjWithFlags)[SENTRY_NAVIGATE_HOOK_INVOKED_FLAG] = true; + + // Skip numeric navigations (history back/forward like navigate(-1)) + // since we can't resolve them to meaningful route names + if (typeof info.to === 'number') { + const result = await callNavigate(); + captureInstrumentationError(result, captureErrors, 'react_router.navigate', { + 'http.url': info.currentUrl, + }); + return; + } + + const client = getClient(); + const toPath = String(info.to); + + if (client) { + startBrowserTracingNavigationSpan(client, { + name: toPath, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react_router.instrumentation_api', + }, + }); + } + + const result = await callNavigate(); + captureInstrumentationError(result, captureErrors, 'react_router.navigate', { + 'http.url': toPath, + }); + }, + + async fetch(callFetch, info) { + await startSpan( + { + name: `Fetcher ${info.fetcherKey}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.fetcher', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.instrumentation_api', + }, + }, + async () => { + const result = await callFetch(); + captureInstrumentationError(result, captureErrors, 'react_router.fetcher', { + 'http.url': info.href, + }); + }, + ); + }, + }); + }, + + route(route: InstrumentableRoute) { + route.instrument({ + async loader(callLoader, info) { + const urlPath = getPathFromRequest(info.request); + const routePattern = normalizeRoutePath(getPattern(info)) || urlPath; + + await startSpan( + { + name: routePattern, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.client-loader', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.instrumentation_api', + }, + }, + async () => { + const result = await callLoader(); + captureInstrumentationError(result, captureErrors, 'react_router.client_loader', { + 'http.url': urlPath, + }); + }, + ); + }, + + async action(callAction, info) { + const urlPath = getPathFromRequest(info.request); + const routePattern = normalizeRoutePath(getPattern(info)) || urlPath; + + await startSpan( + { + name: routePattern, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.client-action', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.instrumentation_api', + }, + }, + async () => { + const result = await callAction(); + captureInstrumentationError(result, captureErrors, 'react_router.client_action', { + 'http.url': urlPath, + }); + }, + ); + }, + + async middleware(callMiddleware, info) { + const urlPath = getPathFromRequest(info.request); + const routePattern = normalizeRoutePath(getPattern(info)) || urlPath; + + await startSpan( + { + name: routePattern, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.client-middleware', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.instrumentation_api', + }, + }, + async () => { + const result = await callMiddleware(); + captureInstrumentationError(result, captureErrors, 'react_router.client_middleware', { + 'http.url': urlPath, + }); + }, + ); + }, + + async lazy(callLazy) { + await startSpan( + { + name: 'Lazy Route Load', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.client-lazy', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.instrumentation_api', + }, + }, + async () => { + const result = await callLazy(); + captureInstrumentationError(result, captureErrors, 'react_router.client_lazy', {}); + }, + ); + }, + }); + }, + }; +} + +/** + * Check if React Router's instrumentation API is being used on the client. + * @experimental + */ +export function isClientInstrumentationApiUsed(): boolean { + return !!(GLOBAL_OBJ as GlobalObjWithFlags)[SENTRY_CLIENT_INSTRUMENTATION_FLAG]; +} + +/** + * Check if React Router's instrumentation API's navigate hook was invoked. + * @experimental + */ +export function isNavigateHookInvoked(): boolean { + return !!(GLOBAL_OBJ as GlobalObjWithFlags)[SENTRY_NAVIGATE_HOOK_INVOKED_FLAG]; +} diff --git a/packages/react-router/src/client/hydratedRouter.ts b/packages/react-router/src/client/hydratedRouter.ts index 14cdf07a33c9..49655d4a3b8d 100644 --- a/packages/react-router/src/client/hydratedRouter.ts +++ b/packages/react-router/src/client/hydratedRouter.ts @@ -1,7 +1,7 @@ import { startBrowserTracingNavigationSpan } from '@sentry/browser'; import type { Span } from '@sentry/core'; import { - consoleSandbox, + debug, getActiveSpan, getClient, getRootSpan, @@ -13,6 +13,7 @@ import { } from '@sentry/core'; import type { DataRouter, RouterState } from 'react-router'; import { DEBUG_BUILD } from '../common/debug-build'; +import { isNavigateHookInvoked } from './createClientInstrumentation'; const GLOBAL_OBJ_WITH_DATA_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & { __reactRouterDataRouter?: DataRouter; @@ -34,7 +35,6 @@ export function instrumentHydratedRouter(): void { if (router) { // The first time we hit the router, we try to update the pageload transaction - // todo: update pageload tx here const pageloadSpan = getActiveRootSpan(); if (pageloadSpan) { @@ -51,18 +51,18 @@ export function instrumentHydratedRouter(): void { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react_router', }); } + } - // 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); - }; - } + // Patching navigate for creating accurate navigation transactions + if (typeof router.navigate === 'function') { + const originalNav = router.navigate.bind(router); + router.navigate = function sentryPatchedNavigate(...args) { + // Skip if instrumentation API is handling navigation (prevents double-counting) + if (!isNavigateHookInvoked()) { + maybeCreateNavigationTransaction(String(args[0]) || '', 'url'); + } + return originalNav(...args); + }; } // Subscribe to router state changes to update navigation transactions with parameterized routes @@ -79,7 +79,8 @@ export function instrumentHydratedRouter(): void { if ( navigationSpanName && newState.navigation.state === 'idle' && // navigation has completed - normalizePathname(newState.location.pathname) === normalizePathname(navigationSpanName) // this event is for the currently active navigation + // this event is for the currently active navigation + normalizePathname(newState.location.pathname) === normalizePathname(navigationSpanName) ) { navigationSpan.updateName(parameterizedNavRoute); navigationSpan.setAttributes({ @@ -100,11 +101,7 @@ export function instrumentHydratedRouter(): void { 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.'); - }); + DEBUG_BUILD && debug.warn('Unable to instrument React Router: router not found after hydration.'); } clearInterval(interval); } diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index ba5c1c1264cb..6734b21c8583 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -4,7 +4,11 @@ export * from '@sentry/browser'; export { init } from './sdk'; -export { reactRouterTracingIntegration } from './tracingIntegration'; +export { + reactRouterTracingIntegration, + type ReactRouterTracingIntegration, + type ReactRouterTracingIntegrationOptions, +} from './tracingIntegration'; export { captureReactException, reactErrorHandler, Profiler, withProfiler, useProfiler } from '@sentry/react'; @@ -19,3 +23,11 @@ export { ErrorBoundary, withErrorBoundary } from '@sentry/react'; * See https://docs.sentry.io/platforms/javascript/guides/react-router/#report-errors-from-error-boundaries */ export type { ErrorBoundaryProps, FallbackRender } from '@sentry/react'; + +// React Router instrumentation API for use with unstable_instrumentations (React Router 7.x) +export { + createSentryClientInstrumentation, + isClientInstrumentationApiUsed, + isNavigateHookInvoked, + type CreateSentryClientInstrumentationOptions, +} from './createClientInstrumentation'; diff --git a/packages/react-router/src/client/tracingIntegration.ts b/packages/react-router/src/client/tracingIntegration.ts index 01b71f36d92a..a711eb986508 100644 --- a/packages/react-router/src/client/tracingIntegration.ts +++ b/packages/react-router/src/client/tracingIntegration.ts @@ -1,17 +1,68 @@ import { browserTracingIntegration as originalBrowserTracingIntegration } from '@sentry/browser'; import type { Integration } from '@sentry/core'; +import type { ClientInstrumentation } from '../common/types'; +import { + createSentryClientInstrumentation, + type CreateSentryClientInstrumentationOptions, +} from './createClientInstrumentation'; import { instrumentHydratedRouter } from './hydratedRouter'; +/** + * Options for the React Router tracing integration. + */ +export interface ReactRouterTracingIntegrationOptions { + /** + * Options for React Router's instrumentation API. + * @experimental + */ + instrumentationOptions?: CreateSentryClientInstrumentationOptions; + + /** + * Enable React Router's instrumentation API. + * When true, prepares for use with HydratedRouter's `unstable_instrumentations` prop. + * @experimental + * @default false + */ + useInstrumentationAPI?: boolean; +} + +/** + * React Router tracing integration with support for the instrumentation API. + */ +export interface ReactRouterTracingIntegration extends Integration { + /** + * Client instrumentation for React Router's instrumentation API. + * Lazily initialized on first access. + * @experimental HydratedRouter doesn't invoke these hooks in Framework Mode yet. + */ + readonly clientInstrumentation: ClientInstrumentation; +} + /** * Browser tracing integration for React Router (Framework) applications. - * This integration will create navigation spans and enhance transactions names with parameterized routes. + * This integration will create navigation spans and enhance transaction names with parameterized routes. */ -export function reactRouterTracingIntegration(): Integration { +export function reactRouterTracingIntegration( + options: ReactRouterTracingIntegrationOptions = {}, +): ReactRouterTracingIntegration { const browserTracingIntegrationInstance = originalBrowserTracingIntegration({ // Navigation transactions are started within the hydrated router instrumentation instrumentNavigation: false, }); + let clientInstrumentationInstance: ClientInstrumentation | undefined; + + if (options.useInstrumentationAPI || options.instrumentationOptions) { + clientInstrumentationInstance = createSentryClientInstrumentation(options.instrumentationOptions); + } + + const getClientInstrumentation = (): ClientInstrumentation => { + if (!clientInstrumentationInstance) { + clientInstrumentationInstance = createSentryClientInstrumentation(options.instrumentationOptions); + } + return clientInstrumentationInstance; + }; + return { ...browserTracingIntegrationInstance, name: 'ReactRouterTracingIntegration', @@ -19,5 +70,8 @@ export function reactRouterTracingIntegration(): Integration { browserTracingIntegrationInstance.afterAllSetup(client); instrumentHydratedRouter(); }, + get clientInstrumentation(): ClientInstrumentation { + return getClientInstrumentation(); + }, }; } diff --git a/packages/react-router/src/common/types.ts b/packages/react-router/src/common/types.ts new file mode 100644 index 000000000000..23cbb174f167 --- /dev/null +++ b/packages/react-router/src/common/types.ts @@ -0,0 +1,96 @@ +/** + * Types for React Router's instrumentation API. + * + * Derived from React Router v7.x `unstable_instrumentations` API. + * The stable `instrumentations` API is planned for React Router v8. + * If React Router changes these types, this file must be updated. + * + * @see https://reactrouter.com/how-to/instrumentation + * @experimental + */ + +export type InstrumentationResult = { status: 'success'; error: undefined } | { status: 'error'; error: unknown }; + +export interface ReadonlyRequest { + method: string; + url: string; + headers: Pick; +} + +export interface RouteHandlerInstrumentationInfo { + readonly request: ReadonlyRequest; + readonly params: Record; + readonly pattern?: string; + readonly unstable_pattern?: string; + readonly context?: unknown; +} + +export interface RouterNavigationInstrumentationInfo { + readonly to: string | number; + readonly currentUrl: string; + readonly formMethod?: string; + readonly formEncType?: string; + readonly formData?: FormData; + readonly body?: unknown; +} + +export interface RouterFetchInstrumentationInfo { + readonly href: string; + readonly currentUrl: string; + readonly fetcherKey: string; + readonly formMethod?: string; + readonly formEncType?: string; + readonly formData?: FormData; + readonly body?: unknown; +} + +export interface RequestHandlerInstrumentationInfo { + readonly request: Request; + readonly context: unknown; +} + +export type InstrumentFunction = (handler: () => Promise, info: T) => Promise; + +export interface RouteInstrumentations { + lazy?: InstrumentFunction; + 'lazy.loader'?: InstrumentFunction; + 'lazy.action'?: InstrumentFunction; + 'lazy.middleware'?: InstrumentFunction; + middleware?: InstrumentFunction; + loader?: InstrumentFunction; + action?: InstrumentFunction; +} + +export interface RouterInstrumentations { + navigate?: InstrumentFunction; + fetch?: InstrumentFunction; +} + +export interface RequestHandlerInstrumentations { + request?: InstrumentFunction; +} + +export interface InstrumentableRoute { + id: string; + index: boolean | undefined; + path: string | undefined; + instrument(instrumentations: RouteInstrumentations): void; +} + +export interface InstrumentableRouter { + instrument(instrumentations: RouterInstrumentations): void; +} + +export interface InstrumentableRequestHandler { + instrument(instrumentations: RequestHandlerInstrumentations): void; +} + +export interface ClientInstrumentation { + router?(router: InstrumentableRouter): void; + route?(route: InstrumentableRoute): void; +} + +export interface ServerInstrumentation { + handler?(handler: InstrumentableRequestHandler): void; + route?(route: InstrumentableRoute): void; +} diff --git a/packages/react-router/src/common/utils.ts b/packages/react-router/src/common/utils.ts new file mode 100644 index 000000000000..36d1c4568f6c --- /dev/null +++ b/packages/react-router/src/common/utils.ts @@ -0,0 +1,72 @@ +import { captureException, debug } from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; +import type { InstrumentationResult } from './types'; + +/** + * Extracts pathname from request URL. + * Falls back to '' with DEBUG warning if URL cannot be parsed. + */ +export function getPathFromRequest(request: { url: string }): string { + try { + return new URL(request.url).pathname; + } catch { + try { + // Fallback: use a dummy base URL since we only care about the pathname + return new URL(request.url, 'http://example.com').pathname; + } catch (error) { + DEBUG_BUILD && debug.warn('Failed to parse URL from request:', request.url, error); + return ''; + } + } +} + +/** + * Extracts route pattern from instrumentation info. + * Prefers `pattern` (planned for v8) over `unstable_pattern` (v7.x). + */ +export function getPattern(info: { pattern?: string; unstable_pattern?: string }): string | undefined { + return info.pattern ?? info.unstable_pattern; +} + +/** + * Normalizes route path by ensuring it starts with a slash. + * Returns undefined if the input is falsy. + */ +export function normalizeRoutePath(pattern?: string): string | undefined { + if (!pattern) { + return undefined; + } + return pattern.startsWith('/') ? pattern : `/${pattern}`; +} + +/** + * Captures an error from instrumentation result if conditions are met. + * Used by both client and server instrumentation to avoid duplication. + * + * Only captures actual Error instances - Response objects and ErrorResponse + * are expected control flow in React Router (redirects, 404s, etc). + */ +export function captureInstrumentationError( + result: InstrumentationResult, + captureErrors: boolean, + mechanismType: string, + data: Record, +): void { + if (result.status === 'error' && captureErrors && isError(result.error)) { + captureException(result.error, { + mechanism: { + type: mechanismType, + handled: false, + }, + data, + }); + } +} + +/** + * Checks if value is an Error instance. + * Response objects and ErrorResponse are not errors - they're expected control flow. + */ +function isError(value: unknown): value is Error { + return value instanceof Error; +} diff --git a/packages/react-router/src/server/createServerInstrumentation.ts b/packages/react-router/src/server/createServerInstrumentation.ts new file mode 100644 index 000000000000..44a93980c04c --- /dev/null +++ b/packages/react-router/src/server/createServerInstrumentation.ts @@ -0,0 +1,240 @@ +import { context } from '@opentelemetry/api'; +import { getRPCMetadata, RPCType } from '@opentelemetry/core'; +import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; +import { + debug, + flushIfServerless, + getActiveSpan, + getCurrentScope, + getRootSpan, + GLOBAL_OBJ, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + startSpan, + updateSpanName, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../common/debug-build'; +import type { InstrumentableRequestHandler, InstrumentableRoute, ServerInstrumentation } from '../common/types'; +import { captureInstrumentationError, getPathFromRequest, getPattern, normalizeRoutePath } from '../common/utils'; + +const SENTRY_SERVER_INSTRUMENTATION_FLAG = '__sentryReactRouterServerInstrumentationUsed'; + +type GlobalObjWithFlag = typeof GLOBAL_OBJ & { + [SENTRY_SERVER_INSTRUMENTATION_FLAG]?: boolean; +}; + +/** + * Options for creating Sentry server instrumentation. + */ +export interface CreateSentryServerInstrumentationOptions { + /** + * Whether to capture errors from loaders/actions automatically. + * @default true + */ + captureErrors?: boolean; +} + +/** + * Creates a Sentry server instrumentation for React Router's instrumentation API. + * @experimental + */ +export function createSentryServerInstrumentation( + options: CreateSentryServerInstrumentationOptions = {}, +): ServerInstrumentation { + const { captureErrors = true } = options; + + (GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_SERVER_INSTRUMENTATION_FLAG] = true; + DEBUG_BUILD && debug.log('React Router server instrumentation API enabled.'); + + return { + handler(handler: InstrumentableRequestHandler) { + handler.instrument({ + async request(handleRequest, info) { + const pathname = getPathFromRequest(info.request); + const activeSpan = getActiveSpan(); + const existingRootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + + if (existingRootSpan) { + updateSpanName(existingRootSpan, `${info.request.method} ${pathname}`); + existingRootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }); + + try { + const result = await handleRequest(); + captureInstrumentationError(result, captureErrors, 'react_router.request_handler', { + 'http.method': info.request.method, + 'http.url': pathname, + }); + } finally { + await flushIfServerless(); + } + } else { + await startSpan( + { + name: `${info.request.method} ${pathname}`, + forceTransaction: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + 'http.request.method': info.request.method, + 'url.path': pathname, + 'url.full': info.request.url, + }, + }, + async () => { + try { + const result = await handleRequest(); + captureInstrumentationError(result, captureErrors, 'react_router.request_handler', { + 'http.method': info.request.method, + 'http.url': pathname, + }); + } finally { + await flushIfServerless(); + } + }, + ); + } + }, + }); + }, + + route(route: InstrumentableRoute) { + route.instrument({ + async loader(callLoader, info) { + const urlPath = getPathFromRequest(info.request); + const pattern = getPattern(info); + const routePattern = normalizeRoutePath(pattern) || urlPath; + updateRootSpanWithRoute(info.request.method, pattern, urlPath); + + await startSpan( + { + name: routePattern, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.instrumentation_api', + }, + }, + async () => { + const result = await callLoader(); + captureInstrumentationError(result, captureErrors, 'react_router.loader', { + 'http.method': info.request.method, + 'http.url': urlPath, + }); + }, + ); + }, + + async action(callAction, info) { + const urlPath = getPathFromRequest(info.request); + const pattern = getPattern(info); + const routePattern = normalizeRoutePath(pattern) || urlPath; + updateRootSpanWithRoute(info.request.method, pattern, urlPath); + + await startSpan( + { + name: routePattern, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.instrumentation_api', + }, + }, + async () => { + const result = await callAction(); + captureInstrumentationError(result, captureErrors, 'react_router.action', { + 'http.method': info.request.method, + 'http.url': urlPath, + }); + }, + ); + }, + + async middleware(callMiddleware, info) { + const urlPath = getPathFromRequest(info.request); + const pattern = getPattern(info); + const routePattern = normalizeRoutePath(pattern) || urlPath; + + // Update root span with parameterized route (same as loader/action) + updateRootSpanWithRoute(info.request.method, pattern, urlPath); + + await startSpan( + { + name: routePattern, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.middleware', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.instrumentation_api', + }, + }, + async () => { + const result = await callMiddleware(); + captureInstrumentationError(result, captureErrors, 'react_router.middleware', { + 'http.method': info.request.method, + 'http.url': urlPath, + }); + }, + ); + }, + + async lazy(callLazy) { + await startSpan( + { + name: 'Lazy Route Load', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.lazy', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.instrumentation_api', + }, + }, + async () => { + const result = await callLazy(); + captureInstrumentationError(result, captureErrors, 'react_router.lazy', {}); + }, + ); + }, + }); + }, + }; +} + +/** + * Check if React Router's instrumentation API is being used on the server. + * @experimental + */ +export function isInstrumentationApiUsed(): boolean { + return !!(GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_SERVER_INSTRUMENTATION_FLAG]; +} + +function updateRootSpanWithRoute(method: string, pattern: string | undefined, urlPath: string): void { + const activeSpan = getActiveSpan(); + if (!activeSpan) return; + const rootSpan = getRootSpan(activeSpan); + if (!rootSpan) return; + + // Skip update if URL path is invalid (failed to parse) + if (!urlPath || urlPath === '') { + DEBUG_BUILD && debug.warn('Cannot update span with invalid URL path:', urlPath); + return; + } + + const hasPattern = !!pattern; + const routeName = hasPattern ? normalizeRoutePath(pattern) || urlPath : urlPath; + + const rpcMetadata = getRPCMetadata(context.active()); + if (rpcMetadata?.type === RPCType.HTTP) { + rpcMetadata.route = routeName; + } + + const transactionName = `${method} ${routeName}`; + updateSpanName(rootSpan, transactionName); + rootSpan.setAttributes({ + [ATTR_HTTP_ROUTE]: routeName, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: hasPattern ? 'route' : 'url', + }); + + // Also update the scope's transaction name so errors captured during this request + // have the correct transaction name (not the initial placeholder like "GET *") + getCurrentScope().setTransactionName(transactionName); +} diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index acca80a94d81..e0b8c8981632 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -11,3 +11,10 @@ export { wrapServerAction } from './wrapServerAction'; export { wrapServerLoader } from './wrapServerLoader'; export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError'; export { getMetaTagTransformer } from './getMetaTagTransformer'; + +// React Router instrumentation API support (works with both unstable_instrumentations and instrumentations) +export { + createSentryServerInstrumentation, + isInstrumentationApiUsed, + type CreateSentryServerInstrumentationOptions, +} from './createServerInstrumentation'; diff --git a/packages/react-router/src/server/instrumentation/reactRouter.ts b/packages/react-router/src/server/instrumentation/reactRouter.ts index 708b9857015b..bfb9831dd568 100644 --- a/packages/react-router/src/server/instrumentation/reactRouter.ts +++ b/packages/react-router/src/server/instrumentation/reactRouter.ts @@ -15,6 +15,7 @@ import { } from '@sentry/core'; import type * as reactRouter from 'react-router'; import { DEBUG_BUILD } from '../../common/debug-build'; +import { isInstrumentationApiUsed } from '../createServerInstrumentation'; import { getOpName, getSpanName, isDataRequest } from './util'; type ReactRouterModuleExports = typeof reactRouter; @@ -76,6 +77,13 @@ export class ReactRouterInstrumentation extends InstrumentationBase { return { name: INTEGRATION_NAME, setupOnce() { + // Skip OTEL patching if the instrumentation API is in use + if (isInstrumentationApiUsed()) { + return; + } + if ( (NODE_VERSION.major === 20 && NODE_VERSION.minor < 19) || // https://nodejs.org/en/blog/release/v20.19.0 (NODE_VERSION.major === 22 && NODE_VERSION.minor < 12) // https://nodejs.org/en/blog/release/v22.12.0 @@ -36,13 +42,17 @@ export const reactRouterServerIntegration = defineIntegration(() => { if ( event.type === 'transaction' && event.contexts?.trace?.data && - event.contexts.trace.data[ATTR_HTTP_ROUTE] === '*' && - // This means the name has been adjusted before, but the http.route remains, so we need to remove it - event.transaction !== 'GET *' && - event.transaction !== 'POST *' + event.contexts.trace.data[ATTR_HTTP_ROUTE] === '*' ) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete event.contexts.trace.data[ATTR_HTTP_ROUTE]; + const origin = event.contexts.trace.origin; + const isInstrumentationApiOrigin = origin?.includes('instrumentation_api'); + + // For instrumentation_api, always clean up bogus `*` route since we set better names + // For legacy, only clean up if the name has been adjusted (not METHOD *) + if (isInstrumentationApiOrigin || !event.transaction?.endsWith(' *')) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete event.contexts.trace.data[ATTR_HTTP_ROUTE]; + } } return event; diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 2e788637988f..9e1adbc2c5ff 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -4,11 +4,14 @@ import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import { flushIfServerless, getActiveSpan, + getCurrentScope, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + updateSpanName, } from '@sentry/core'; import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router'; +import { isInstrumentationApiUsed } from './createServerInstrumentation'; type OriginalHandleRequestWithoutMiddleware = ( request: Request, @@ -67,7 +70,8 @@ export function wrapSentryHandleRequest( const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; if (parameterizedPath && rootSpan) { - const routeName = `/${parameterizedPath}`; + // Normalize route name - avoid "//" for root routes + const routeName = parameterizedPath.startsWith('/') ? parameterizedPath : `/${parameterizedPath}`; // The express instrumentation writes on the rpcMetadata and that ends up stomping on the `http.route` attribute. const rpcMetadata = getRPCMetadata(context.active()); @@ -76,12 +80,25 @@ export function wrapSentryHandleRequest( rpcMetadata.route = routeName; } - // The span exporter picks up the `http.route` (ATTR_HTTP_ROUTE) attribute to set the transaction name - rootSpan.setAttributes({ - [ATTR_HTTP_ROUTE]: routeName, - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler', - }); + const transactionName = `${request.method} ${routeName}`; + + updateSpanName(rootSpan, transactionName); + getCurrentScope().setTransactionName(transactionName); + + // Set route attributes - acts as fallback for lazy-only routes when using instrumentation API + // Don't override origin when instrumentation API is used (preserve instrumentation_api origin) + if (isInstrumentationApiUsed()) { + rootSpan.setAttributes({ + [ATTR_HTTP_ROUTE]: routeName, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }); + } else { + rootSpan.setAttributes({ + [ATTR_HTTP_ROUTE]: routeName, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler', + }); + } } try { diff --git a/packages/react-router/src/server/wrapServerAction.ts b/packages/react-router/src/server/wrapServerAction.ts index e816c3c63886..5a4b253e7e19 100644 --- a/packages/react-router/src/server/wrapServerAction.ts +++ b/packages/react-router/src/server/wrapServerAction.ts @@ -1,6 +1,7 @@ import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import type { SpanAttributes } from '@sentry/core'; import { + debug, flushIfServerless, getActiveSpan, getRootSpan, @@ -12,12 +13,17 @@ import { updateSpanName, } from '@sentry/core'; import type { ActionFunctionArgs } from 'react-router'; +import { DEBUG_BUILD } from '../common/debug-build'; +import { isInstrumentationApiUsed } from './createServerInstrumentation'; type SpanOptions = { name?: string; attributes?: SpanAttributes; }; +// Track if we've already warned about duplicate instrumentation +let hasWarnedAboutDuplicateActionInstrumentation = false; + /** * Wraps a React Router server action function with Sentry performance monitoring. * @param options - Optional span configuration options including name, operation, description and attributes @@ -37,8 +43,23 @@ type SpanOptions = { * ); * ``` */ -export function wrapServerAction(options: SpanOptions = {}, actionFn: (args: ActionFunctionArgs) => Promise) { - return async function (args: ActionFunctionArgs) { +export function wrapServerAction( + options: SpanOptions = {}, + actionFn: (args: ActionFunctionArgs) => Promise, +): (args: ActionFunctionArgs) => Promise { + return async function (args: ActionFunctionArgs): Promise { + // Skip instrumentation if instrumentation API is already handling it + if (isInstrumentationApiUsed()) { + if (DEBUG_BUILD && !hasWarnedAboutDuplicateActionInstrumentation) { + hasWarnedAboutDuplicateActionInstrumentation = true; + debug.warn( + 'wrapServerAction is redundant when using the instrumentation API. ' + + 'The action is already instrumented automatically. You can safely remove wrapServerAction.', + ); + } + return actionFn(args); + } + const name = options.name || 'Executing Server Action'; const active = getActiveSpan(); if (active) { diff --git a/packages/react-router/src/server/wrapServerLoader.ts b/packages/react-router/src/server/wrapServerLoader.ts index 7e5083d4d5c8..d9b0dc521743 100644 --- a/packages/react-router/src/server/wrapServerLoader.ts +++ b/packages/react-router/src/server/wrapServerLoader.ts @@ -1,6 +1,7 @@ import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import type { SpanAttributes } from '@sentry/core'; import { + debug, flushIfServerless, getActiveSpan, getRootSpan, @@ -12,12 +13,17 @@ import { updateSpanName, } from '@sentry/core'; import type { LoaderFunctionArgs } from 'react-router'; +import { DEBUG_BUILD } from '../common/debug-build'; +import { isInstrumentationApiUsed } from './createServerInstrumentation'; type SpanOptions = { name?: string; attributes?: SpanAttributes; }; +// Track if we've already warned about duplicate instrumentation +let hasWarnedAboutDuplicateLoaderInstrumentation = false; + /** * Wraps a React Router server loader function with Sentry performance monitoring. * @param options - Optional span configuration options including name, operation, description and attributes @@ -37,8 +43,23 @@ type SpanOptions = { * ); * ``` */ -export function wrapServerLoader(options: SpanOptions = {}, loaderFn: (args: LoaderFunctionArgs) => Promise) { - return async function (args: LoaderFunctionArgs) { +export function wrapServerLoader( + options: SpanOptions = {}, + loaderFn: (args: LoaderFunctionArgs) => Promise, +): (args: LoaderFunctionArgs) => Promise { + return async function (args: LoaderFunctionArgs): Promise { + // Skip instrumentation if instrumentation API is already handling it + if (isInstrumentationApiUsed()) { + if (DEBUG_BUILD && !hasWarnedAboutDuplicateLoaderInstrumentation) { + hasWarnedAboutDuplicateLoaderInstrumentation = true; + debug.warn( + 'wrapServerLoader is redundant when using the instrumentation API. ' + + 'The loader is already instrumented automatically. You can safely remove wrapServerLoader.', + ); + } + return loaderFn(args); + } + const name = options.name || 'Executing Server Loader'; const active = getActiveSpan(); diff --git a/packages/react-router/test/client/createClientInstrumentation.test.ts b/packages/react-router/test/client/createClientInstrumentation.test.ts new file mode 100644 index 000000000000..ef885816905f --- /dev/null +++ b/packages/react-router/test/client/createClientInstrumentation.test.ts @@ -0,0 +1,451 @@ +import * as browser from '@sentry/browser'; +import * as core from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createSentryClientInstrumentation, + isClientInstrumentationApiUsed, + isNavigateHookInvoked, +} from '../../src/client/createClientInstrumentation'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startSpan: vi.fn(), + captureException: vi.fn(), + getClient: vi.fn(), + GLOBAL_OBJ: globalThis, + SEMANTIC_ATTRIBUTE_SENTRY_OP: 'sentry.op', + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', + }; +}); + +vi.mock('@sentry/browser', () => ({ + startBrowserTracingNavigationSpan: vi.fn(), +})); + +describe('createSentryClientInstrumentation', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset global flag + delete (globalThis as any).__sentryReactRouterClientInstrumentationUsed; + }); + + afterEach(() => { + delete (globalThis as any).__sentryReactRouterClientInstrumentationUsed; + }); + + it('should create a valid client instrumentation object', () => { + const instrumentation = createSentryClientInstrumentation(); + + expect(instrumentation).toBeDefined(); + expect(typeof instrumentation.router).toBe('function'); + expect(typeof instrumentation.route).toBe('function'); + }); + + it('should set the global flag when created', () => { + expect((globalThis as any).__sentryReactRouterClientInstrumentationUsed).toBeUndefined(); + + createSentryClientInstrumentation(); + + expect((globalThis as any).__sentryReactRouterClientInstrumentationUsed).toBe(true); + }); + + it('should instrument router navigate with browser tracing span', async () => { + const mockCallNavigate = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + const mockClient = {}; + + (core.getClient as any).mockReturnValue(mockClient); + + const instrumentation = createSentryClientInstrumentation(); + instrumentation.router?.({ instrument: mockInstrument }); + + expect(mockInstrument).toHaveBeenCalled(); + const hooks = mockInstrument.mock.calls[0]![0]; + + // Call the navigate hook with proper info structure + await hooks.navigate(mockCallNavigate, { + currentUrl: '/home', + to: '/about', + }); + + expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith(mockClient, { + name: '/about', + attributes: expect.objectContaining({ + 'sentry.source': 'url', + 'sentry.op': 'navigation', + 'sentry.origin': 'auto.navigation.react_router.instrumentation_api', + }), + }); + expect(mockCallNavigate).toHaveBeenCalled(); + }); + + it('should instrument router fetch with spans', async () => { + const mockCallFetch = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + + const instrumentation = createSentryClientInstrumentation(); + instrumentation.router?.({ instrument: mockInstrument }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + // Call the fetch hook with proper info structure + await hooks.fetch(mockCallFetch, { + href: '/api/data', + currentUrl: '/home', + fetcherKey: 'fetcher-1', + }); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Fetcher fetcher-1', + attributes: expect.objectContaining({ + 'sentry.op': 'function.react-router.fetcher', + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + }), + }), + expect.any(Function), + ); + expect(mockCallFetch).toHaveBeenCalled(); + }); + + it('should instrument route loader with spans', async () => { + const mockCallLoader = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + + const instrumentation = createSentryClientInstrumentation(); + // Route has id, index, path as required properties + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/test', + instrument: mockInstrument, + }); + + expect(mockInstrument).toHaveBeenCalled(); + const hooks = mockInstrument.mock.calls[0]![0]; + + // Call the loader hook with RouteHandlerInstrumentationInfo + await hooks.loader(mockCallLoader, { + request: { method: 'GET', url: 'http://example.com/users/123', headers: { get: () => null } }, + params: { id: '123' }, + unstable_pattern: '/users/:id', + context: undefined, + }); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '/users/:id', + attributes: expect.objectContaining({ + 'sentry.op': 'function.react-router.client-loader', + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + }), + }), + expect.any(Function), + ); + expect(mockCallLoader).toHaveBeenCalled(); + }); + + it('should instrument route action with spans', async () => { + const mockCallAction = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + + const instrumentation = createSentryClientInstrumentation(); + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/test', + instrument: mockInstrument, + }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + // Call the action hook with RouteHandlerInstrumentationInfo + await hooks.action(mockCallAction, { + request: { method: 'POST', url: 'http://example.com/users/123', headers: { get: () => null } }, + params: { id: '123' }, + unstable_pattern: '/users/:id', + context: undefined, + }); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '/users/:id', + attributes: expect.objectContaining({ + 'sentry.op': 'function.react-router.client-action', + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + }), + }), + expect.any(Function), + ); + }); + + it('should capture errors when captureErrors is true (default)', async () => { + const mockError = new Error('Test error'); + // React Router returns an error result, not a rejection + const mockCallLoader = vi.fn().mockResolvedValue({ status: 'error', error: mockError }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + + const instrumentation = createSentryClientInstrumentation(); + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/test', + instrument: mockInstrument, + }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.loader(mockCallLoader, { + request: { method: 'GET', url: 'http://example.com/test-path', headers: { get: () => null } }, + params: {}, + unstable_pattern: '/test-path', + context: undefined, + }); + + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { type: 'react_router.client_loader', handled: false }, + data: { + 'http.url': '/test-path', + }, + }); + }); + + it('should not capture errors when captureErrors is false', async () => { + const mockError = new Error('Test error'); + // React Router returns an error result, not a rejection + const mockCallLoader = vi.fn().mockResolvedValue({ status: 'error', error: mockError }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + + const instrumentation = createSentryClientInstrumentation({ captureErrors: false }); + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/test', + instrument: mockInstrument, + }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.loader(mockCallLoader, { + request: { method: 'GET', url: 'http://example.com/test-path', headers: { get: () => null } }, + params: {}, + unstable_pattern: '/test-path', + context: undefined, + }); + + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should capture navigate errors', async () => { + const mockError = new Error('Navigation error'); + // React Router returns an error result, not a rejection + const mockCallNavigate = vi.fn().mockResolvedValue({ status: 'error', error: mockError }); + const mockInstrument = vi.fn(); + + (core.getClient as any).mockReturnValue({}); + + const instrumentation = createSentryClientInstrumentation(); + instrumentation.router?.({ instrument: mockInstrument }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.navigate(mockCallNavigate, { + currentUrl: '/home', + to: '/about', + }); + + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { type: 'react_router.navigate', handled: false }, + data: { + 'http.url': '/about', + }, + }); + }); + + it('should fall back to URL pathname when unstable_pattern is undefined', async () => { + const mockCallLoader = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + + const instrumentation = createSentryClientInstrumentation(); + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/test', + instrument: mockInstrument, + }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + // Call with undefined unstable_pattern - should fall back to pathname + await hooks.loader(mockCallLoader, { + request: { method: 'GET', url: 'http://example.com/users/123', headers: { get: () => null } }, + params: { id: '123' }, + unstable_pattern: undefined, + context: undefined, + }); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '/users/123', + }), + expect.any(Function), + ); + }); + + it('should instrument route middleware with spans', async () => { + const mockCallMiddleware = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + + const instrumentation = createSentryClientInstrumentation(); + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/users/:id', + instrument: mockInstrument, + }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.middleware(mockCallMiddleware, { + request: { method: 'GET', url: 'http://example.com/users/123', headers: { get: () => null } }, + params: { id: '123' }, + unstable_pattern: '/users/:id', + context: undefined, + }); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '/users/:id', + attributes: expect.objectContaining({ + 'sentry.op': 'function.react-router.client-middleware', + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + }), + }), + expect.any(Function), + ); + }); + + it('should instrument lazy route loading with spans', async () => { + const mockCallLazy = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + + const instrumentation = createSentryClientInstrumentation(); + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/users/:id', + instrument: mockInstrument, + }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.lazy(mockCallLazy, undefined); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Lazy Route Load', + attributes: expect.objectContaining({ + 'sentry.op': 'function.react-router.client-lazy', + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + }), + }), + expect.any(Function), + ); + }); +}); + +describe('isClientInstrumentationApiUsed', () => { + beforeEach(() => { + delete (globalThis as any).__sentryReactRouterClientInstrumentationUsed; + }); + + afterEach(() => { + delete (globalThis as any).__sentryReactRouterClientInstrumentationUsed; + }); + + it('should return false when flag is not set', () => { + expect(isClientInstrumentationApiUsed()).toBe(false); + }); + + it('should return true when flag is set', () => { + (globalThis as any).__sentryReactRouterClientInstrumentationUsed = true; + expect(isClientInstrumentationApiUsed()).toBe(true); + }); + + it('should return true after createSentryClientInstrumentation is called', () => { + expect(isClientInstrumentationApiUsed()).toBe(false); + createSentryClientInstrumentation(); + expect(isClientInstrumentationApiUsed()).toBe(true); + }); +}); + +describe('isNavigateHookInvoked', () => { + beforeEach(() => { + delete (globalThis as any).__sentryReactRouterNavigateHookInvoked; + delete (globalThis as any).__sentryReactRouterClientInstrumentationUsed; + }); + + afterEach(() => { + delete (globalThis as any).__sentryReactRouterNavigateHookInvoked; + delete (globalThis as any).__sentryReactRouterClientInstrumentationUsed; + }); + + it('should return false when flag is not set', () => { + expect(isNavigateHookInvoked()).toBe(false); + }); + + it('should return true when flag is set', () => { + (globalThis as any).__sentryReactRouterNavigateHookInvoked = true; + expect(isNavigateHookInvoked()).toBe(true); + }); + + it('should return false after createSentryClientInstrumentation is called (before navigate)', () => { + createSentryClientInstrumentation(); + // Flag should not be set just by creating instrumentation + // It only gets set when the navigate hook is actually invoked + expect(isNavigateHookInvoked()).toBe(false); + }); + + it('should return true after navigate hook is invoked', async () => { + const mockCallNavigate = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + + (core.getClient as any).mockReturnValue({}); + + const instrumentation = createSentryClientInstrumentation(); + instrumentation.router?.({ instrument: mockInstrument }); + + // Before navigation, flag should be false + expect(isNavigateHookInvoked()).toBe(false); + + const hooks = mockInstrument.mock.calls[0]![0]; + + // Call the navigate hook + await hooks.navigate(mockCallNavigate, { + currentUrl: '/home', + to: '/about', + }); + + // After navigation, flag should be true + expect(isNavigateHookInvoked()).toBe(true); + }); +}); diff --git a/packages/react-router/test/client/hydratedRouter.test.ts b/packages/react-router/test/client/hydratedRouter.test.ts index 3e798e829566..fdfdb1b92929 100644 --- a/packages/react-router/test/client/hydratedRouter.test.ts +++ b/packages/react-router/test/client/hydratedRouter.test.ts @@ -11,6 +11,9 @@ vi.mock('@sentry/core', async () => { getRootSpan: vi.fn(), spanToJSON: vi.fn(), getClient: vi.fn(), + debug: { + warn: vi.fn(), + }, SEMANTIC_ATTRIBUTE_SENTRY_OP: 'op', SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'origin', SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'source', @@ -108,4 +111,48 @@ describe('instrumentHydratedRouter', () => { expect(mockNavigationSpan.updateName).not.toHaveBeenCalled(); expect(mockNavigationSpan.setAttributes).not.toHaveBeenCalled(); }); + + it('skips navigation span creation when instrumentation API navigate hook has been invoked', () => { + // Simulate that the instrumentation API's navigate hook has been invoked + // (meaning React Router is invoking the hooks and we should avoid double-counting) + (globalThis as any).__sentryReactRouterNavigateHookInvoked = true; + + instrumentHydratedRouter(); + mockRouter.navigate('/bar'); + + // Should not create a navigation span because instrumentation API is handling it + expect(browser.startBrowserTracingNavigationSpan).not.toHaveBeenCalled(); + + // Clean up + delete (globalThis as any).__sentryReactRouterNavigateHookInvoked; + }); + + it('creates navigation span when instrumentation API navigate hook has not been invoked', () => { + // Ensure the flag is not set (default state) + delete (globalThis as any).__sentryReactRouterNavigateHookInvoked; + + instrumentHydratedRouter(); + mockRouter.navigate('/bar'); + + // Should create a navigation span because instrumentation API is not handling it + expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalled(); + }); + + it('should warn when router is not found after max retries', () => { + vi.useFakeTimers(); + + // Remove the router to simulate it not being available + delete (globalThis as any).__reactRouterDataRouter; + + instrumentHydratedRouter(); + + // Advance timers past MAX_RETRIES (40 retries × 50ms = 2000ms) + vi.advanceTimersByTime(2100); + + expect(core.debug.warn).toHaveBeenCalledWith( + 'Unable to instrument React Router: router not found after hydration.', + ); + + vi.useRealTimers(); + }); }); diff --git a/packages/react-router/test/client/tracingIntegration.test.ts b/packages/react-router/test/client/tracingIntegration.test.ts index 2469c9b29db6..b11ccc4c0b0b 100644 --- a/packages/react-router/test/client/tracingIntegration.test.ts +++ b/packages/react-router/test/client/tracingIntegration.test.ts @@ -1,12 +1,22 @@ import * as sentryBrowser from '@sentry/browser'; import type { Client } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import * as hydratedRouterModule from '../../src/client/hydratedRouter'; import { reactRouterTracingIntegration } from '../../src/client/tracingIntegration'; +// Global flag used by client instrumentation API +const SENTRY_CLIENT_INSTRUMENTATION_FLAG = '__sentryReactRouterClientInstrumentationUsed'; + +type GlobalObjWithFlag = typeof GLOBAL_OBJ & { + [SENTRY_CLIENT_INSTRUMENTATION_FLAG]?: boolean; +}; + describe('reactRouterTracingIntegration', () => { afterEach(() => { vi.clearAllMocks(); + // Clean up global flag between tests + (GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_CLIENT_INSTRUMENTATION_FLAG] = undefined; }); it('returns an integration with the correct name and properties', () => { @@ -28,4 +38,91 @@ describe('reactRouterTracingIntegration', () => { expect(browserTracingSpy).toHaveBeenCalled(); expect(instrumentSpy).toHaveBeenCalled(); }); + + describe('clientInstrumentation', () => { + it('provides clientInstrumentation property', () => { + const integration = reactRouterTracingIntegration(); + + expect(integration.clientInstrumentation).toBeDefined(); + }); + + it('lazily creates clientInstrumentation only when accessed', () => { + const integration = reactRouterTracingIntegration(); + + // Flag should not be set yet (lazy initialization) + expect((GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_CLIENT_INSTRUMENTATION_FLAG]).toBeUndefined(); + + // Access the instrumentation + const instrumentation = integration.clientInstrumentation; + + // Now the flag should be set + expect((GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_CLIENT_INSTRUMENTATION_FLAG]).toBe(true); + expect(instrumentation).toBeDefined(); + expect(typeof instrumentation.router).toBe('function'); + expect(typeof instrumentation.route).toBe('function'); + }); + + it('returns the same clientInstrumentation instance on multiple accesses', () => { + const integration = reactRouterTracingIntegration(); + + const first = integration.clientInstrumentation; + const second = integration.clientInstrumentation; + + expect(first).toBe(second); + }); + + it('passes options to createSentryClientInstrumentation', () => { + const integration = reactRouterTracingIntegration({ + instrumentationOptions: { + captureErrors: false, + }, + }); + + const instrumentation = integration.clientInstrumentation; + + // The instrumentation is created - we can verify by checking it has the expected shape + expect(instrumentation).toBeDefined(); + expect(typeof instrumentation.router).toBe('function'); + expect(typeof instrumentation.route).toBe('function'); + }); + + it('eagerly creates instrumentation when useInstrumentationAPI is true', () => { + // Flag should not be set before creating integration + expect((GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_CLIENT_INSTRUMENTATION_FLAG]).toBeUndefined(); + + // Create integration with useInstrumentationAPI: true + reactRouterTracingIntegration({ useInstrumentationAPI: true }); + + // Flag should be set immediately (eager initialization), not waiting for getter access + expect((GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_CLIENT_INSTRUMENTATION_FLAG]).toBe(true); + }); + + it('eagerly creates instrumentation when instrumentationOptions is provided', () => { + expect((GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_CLIENT_INSTRUMENTATION_FLAG]).toBeUndefined(); + + reactRouterTracingIntegration({ instrumentationOptions: {} }); + + // Flag should be set immediately due to options presence + expect((GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_CLIENT_INSTRUMENTATION_FLAG]).toBe(true); + }); + + it('calls instrumentHydratedRouter when useInstrumentationAPI is true', () => { + vi.spyOn(sentryBrowser, 'browserTracingIntegration').mockImplementation(() => ({ + setup: vi.fn(), + afterAllSetup: vi.fn(), + name: 'BrowserTracing', + })); + const instrumentSpy = vi.spyOn(hydratedRouterModule, 'instrumentHydratedRouter').mockImplementation(() => null); + + // Create with useInstrumentationAPI - flag is set eagerly + const integration = reactRouterTracingIntegration({ useInstrumentationAPI: true }); + + // afterAllSetup runs + integration.afterAllSetup?.({} as Client); + + // instrumentHydratedRouter is called for both pageload and navigation handling + // (In Framework Mode, HydratedRouter doesn't invoke client hooks, so legacy instrumentation remains active) + expect(instrumentSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/react-router/test/common/utils.test.ts b/packages/react-router/test/common/utils.test.ts new file mode 100644 index 000000000000..4dc0cbf288d2 --- /dev/null +++ b/packages/react-router/test/common/utils.test.ts @@ -0,0 +1,144 @@ +import * as core from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + captureInstrumentationError, + getPathFromRequest, + getPattern, + normalizeRoutePath, +} from '../../src/common/utils'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + captureException: vi.fn(), + }; +}); + +describe('getPathFromRequest', () => { + it('should extract pathname from valid absolute URL', () => { + const request = { url: 'http://example.com/users/123' }; + expect(getPathFromRequest(request)).toBe('/users/123'); + }); + + it('should extract pathname from relative URL using dummy base', () => { + const request = { url: '/api/data' }; + expect(getPathFromRequest(request)).toBe('/api/data'); + }); + + it('should handle malformed URLs by treating them as relative paths', () => { + // The dummy base URL fallback handles most strings as relative paths + // This verifies the fallback works even for unusual URL strings + const request = { url: ':::invalid:::' }; + expect(getPathFromRequest(request)).toBe('/:::invalid:::'); + }); + + it('should handle URL with query string', () => { + const request = { url: 'http://example.com/search?q=test' }; + expect(getPathFromRequest(request)).toBe('/search'); + }); + + it('should handle URL with fragment', () => { + const request = { url: 'http://example.com/page#section' }; + expect(getPathFromRequest(request)).toBe('/page'); + }); + + it('should handle root path', () => { + const request = { url: 'http://example.com/' }; + expect(getPathFromRequest(request)).toBe('/'); + }); +}); + +describe('getPattern', () => { + it('should prefer stable pattern over unstable_pattern', () => { + const info = { pattern: '/users/:id', unstable_pattern: '/old/:id' }; + expect(getPattern(info)).toBe('/users/:id'); + }); + + it('should fall back to unstable_pattern when pattern is undefined', () => { + const info = { unstable_pattern: '/users/:id' }; + expect(getPattern(info)).toBe('/users/:id'); + }); + + it('should return undefined when neither is available', () => { + const info = {}; + expect(getPattern(info)).toBeUndefined(); + }); +}); + +describe('normalizeRoutePath', () => { + it('should add leading slash if missing', () => { + expect(normalizeRoutePath('users/:id')).toBe('/users/:id'); + }); + + it('should keep existing leading slash', () => { + expect(normalizeRoutePath('/users/:id')).toBe('/users/:id'); + }); + + it('should return undefined for falsy input', () => { + expect(normalizeRoutePath(undefined)).toBeUndefined(); + expect(normalizeRoutePath('')).toBeUndefined(); + }); +}); + +describe('captureInstrumentationError', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should capture error when result is error and captureErrors is true', () => { + const error = new Error('test error'); + const result = { status: 'error' as const, error }; + const data = { 'http.url': '/test' }; + + captureInstrumentationError(result, true, 'react_router.loader', data); + + expect(core.captureException).toHaveBeenCalledWith(error, { + mechanism: { type: 'react_router.loader', handled: false }, + data, + }); + }); + + it('should not capture error when captureErrors is false', () => { + const error = new Error('test error'); + const result = { status: 'error' as const, error }; + + captureInstrumentationError(result, false, 'react_router.loader', {}); + + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should not capture when result is success', () => { + const result = { status: 'success' as const, error: undefined }; + + captureInstrumentationError(result, true, 'react_router.loader', {}); + + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should not capture Response objects (redirects are expected control flow)', () => { + const response = new Response(null, { status: 302 }); + const result = { status: 'error' as const, error: response }; + + captureInstrumentationError(result, true, 'react_router.loader', {}); + + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should not capture non-Error objects (e.g., ErrorResponse)', () => { + const errorResponse = { status: 404, data: 'Not found' }; + const result = { status: 'error' as const, error: errorResponse }; + + captureInstrumentationError(result, true, 'react_router.loader', {}); + + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should not capture string errors', () => { + const result = { status: 'error' as const, error: 'Something went wrong' }; + + captureInstrumentationError(result, true, 'react_router.loader', {}); + + expect(core.captureException).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-router/test/server/createServerInstrumentation.test.ts b/packages/react-router/test/server/createServerInstrumentation.test.ts new file mode 100644 index 000000000000..89135f5d7061 --- /dev/null +++ b/packages/react-router/test/server/createServerInstrumentation.test.ts @@ -0,0 +1,432 @@ +import * as core from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createSentryServerInstrumentation, + isInstrumentationApiUsed, +} from '../../src/server/createServerInstrumentation'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startSpan: vi.fn(), + captureException: vi.fn(), + flushIfServerless: vi.fn(), + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + updateSpanName: vi.fn(), + GLOBAL_OBJ: globalThis, + SEMANTIC_ATTRIBUTE_SENTRY_OP: 'sentry.op', + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', + }; +}); + +describe('createSentryServerInstrumentation', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset global flag + delete (globalThis as any).__sentryReactRouterServerInstrumentationUsed; + }); + + afterEach(() => { + delete (globalThis as any).__sentryReactRouterServerInstrumentationUsed; + }); + + it('should create a valid server instrumentation object', () => { + const instrumentation = createSentryServerInstrumentation(); + + expect(instrumentation).toBeDefined(); + expect(typeof instrumentation.handler).toBe('function'); + expect(typeof instrumentation.route).toBe('function'); + }); + + it('should set the global flag when created', () => { + expect((globalThis as any).__sentryReactRouterServerInstrumentationUsed).toBeUndefined(); + + createSentryServerInstrumentation(); + + expect((globalThis as any).__sentryReactRouterServerInstrumentationUsed).toBe(true); + }); + + it('should update root span with handler request attributes', async () => { + const mockRequest = new Request('http://example.com/test-path'); + const mockHandleRequest = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + const mockSetAttributes = vi.fn(); + const mockRootSpan = { setAttributes: mockSetAttributes }; + + (core.getActiveSpan as any).mockReturnValue({}); + (core.getRootSpan as any).mockReturnValue(mockRootSpan); + + const instrumentation = createSentryServerInstrumentation(); + instrumentation.handler?.({ instrument: mockInstrument }); + + expect(mockInstrument).toHaveBeenCalled(); + const hooks = mockInstrument.mock.calls[0]![0]; + + // Call the request hook with RequestHandlerInstrumentationInfo + await hooks.request(mockHandleRequest, { request: mockRequest, context: undefined }); + + // Should update the root span name and attributes + expect(core.updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /test-path'); + expect(mockSetAttributes).toHaveBeenCalledWith({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.instrumentation_api', + 'sentry.source': 'url', + }); + expect(mockHandleRequest).toHaveBeenCalled(); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should create own root span when no active span exists', async () => { + const mockRequest = new Request('http://example.com/api/users'); + const mockHandleRequest = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + + // No active span exists + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + + const instrumentation = createSentryServerInstrumentation(); + instrumentation.handler?.({ instrument: mockInstrument }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.request(mockHandleRequest, { request: mockRequest, context: undefined }); + + // Should create a new root span with forceTransaction + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'GET /api/users', + forceTransaction: true, + attributes: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.instrumentation_api', + 'sentry.source': 'url', + 'http.request.method': 'GET', + 'url.path': '/api/users', + 'url.full': 'http://example.com/api/users', + }), + }), + expect.any(Function), + ); + expect(mockHandleRequest).toHaveBeenCalled(); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should capture errors in handler when no root span exists', async () => { + const mockRequest = new Request('http://example.com/api/users'); + const mockError = new Error('Handler error'); + const mockHandleRequest = vi.fn().mockResolvedValue({ status: 'error', error: mockError }); + const mockInstrument = vi.fn(); + + (core.getActiveSpan as any).mockReturnValue(undefined); + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + + const instrumentation = createSentryServerInstrumentation(); + instrumentation.handler?.({ instrument: mockInstrument }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.request(mockHandleRequest, { request: mockRequest, context: undefined }); + + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { type: 'react_router.request_handler', handled: false }, + data: { + 'http.method': 'GET', + 'http.url': '/api/users', + }, + }); + }); + + it('should handle invalid URL gracefully and still call handler', async () => { + // Create a request object with an invalid URL that will fail URL parsing + const mockRequest = { url: 'not-a-valid-url', method: 'GET' } as unknown as Request; + const mockHandleRequest = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + + const instrumentation = createSentryServerInstrumentation(); + instrumentation.handler?.({ instrument: mockInstrument }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.request(mockHandleRequest, { request: mockRequest, context: undefined }); + + // Handler should still be called even if URL parsing fails + expect(mockHandleRequest).toHaveBeenCalled(); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should handle relative URLs by using a dummy base', async () => { + const mockRequest = { url: '/relative/path', method: 'GET' } as unknown as Request; + const mockHandleRequest = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + const mockSetAttributes = vi.fn(); + const mockRootSpan = { setAttributes: mockSetAttributes }; + + (core.getActiveSpan as any).mockReturnValue({}); + (core.getRootSpan as any).mockReturnValue(mockRootSpan); + + const instrumentation = createSentryServerInstrumentation(); + instrumentation.handler?.({ instrument: mockInstrument }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.request(mockHandleRequest, { request: mockRequest, context: undefined }); + + expect(core.updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /relative/path'); + }); + + it('should instrument route loader with spans', async () => { + const mockCallLoader = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + (core.getActiveSpan as any).mockReturnValue({}); + (core.getRootSpan as any).mockReturnValue({ setAttributes: vi.fn() }); + + const instrumentation = createSentryServerInstrumentation(); + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/users/:id', + instrument: mockInstrument, + }); + + expect(mockInstrument).toHaveBeenCalled(); + const hooks = mockInstrument.mock.calls[0]![0]; + + // Call the loader hook with RouteHandlerInstrumentationInfo + await hooks.loader(mockCallLoader, { + request: { method: 'GET', url: 'http://example.com/users/123', headers: { get: () => null } }, + params: { id: '123' }, + unstable_pattern: '/users/:id', + context: undefined, + }); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '/users/:id', + attributes: expect.objectContaining({ + 'sentry.op': 'function.react-router.loader', + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + }), + }), + expect.any(Function), + ); + expect(mockCallLoader).toHaveBeenCalled(); + expect(core.updateSpanName).toHaveBeenCalled(); + }); + + it('should instrument route action with spans', async () => { + const mockCallAction = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + (core.getActiveSpan as any).mockReturnValue({}); + (core.getRootSpan as any).mockReturnValue({ setAttributes: vi.fn() }); + + const instrumentation = createSentryServerInstrumentation(); + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/users/:id', + instrument: mockInstrument, + }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + // Call the action hook with RouteHandlerInstrumentationInfo + await hooks.action(mockCallAction, { + request: { method: 'POST', url: 'http://example.com/users/123', headers: { get: () => null } }, + params: { id: '123' }, + unstable_pattern: '/users/:id', + context: undefined, + }); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '/users/:id', + attributes: expect.objectContaining({ + 'sentry.op': 'function.react-router.action', + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + }), + }), + expect.any(Function), + ); + }); + + it('should instrument route middleware with spans', async () => { + const mockCallMiddleware = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + const mockSetAttributes = vi.fn(); + const mockRootSpan = { setAttributes: mockSetAttributes }; + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + (core.getActiveSpan as any).mockReturnValue({}); + (core.getRootSpan as any).mockReturnValue(mockRootSpan); + + const instrumentation = createSentryServerInstrumentation(); + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/users/:id', + instrument: mockInstrument, + }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + // Call the middleware hook with RouteHandlerInstrumentationInfo + await hooks.middleware(mockCallMiddleware, { + request: { method: 'GET', url: 'http://example.com/users/123', headers: { get: () => null } }, + params: { id: '123' }, + unstable_pattern: '/users/:id', + context: undefined, + }); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '/users/:id', + attributes: expect.objectContaining({ + 'sentry.op': 'function.react-router.middleware', + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + }), + }), + expect.any(Function), + ); + + // Verify updateRootSpanWithRoute was called (same as loader/action) + // This updates the root span name and sets http.route for parameterized routes + expect(core.updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /users/:id'); + expect(mockSetAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'http.route': '/users/:id', + 'sentry.source': 'route', + }), + ); + }); + + it('should instrument lazy route loading with spans', async () => { + const mockCallLazy = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + + const instrumentation = createSentryServerInstrumentation(); + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/users/:id', + instrument: mockInstrument, + }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + // Call the lazy hook - info is undefined for lazy loading + await hooks.lazy(mockCallLazy, undefined); + + expect(core.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Lazy Route Load', + attributes: expect.objectContaining({ + 'sentry.op': 'function.react-router.lazy', + 'sentry.origin': 'auto.function.react_router.instrumentation_api', + }), + }), + expect.any(Function), + ); + expect(mockCallLazy).toHaveBeenCalled(); + }); + + it('should capture errors when captureErrors is true (default)', async () => { + const mockError = new Error('Test error'); + // React Router returns an error result, not a rejection + const mockCallLoader = vi.fn().mockResolvedValue({ status: 'error', error: mockError }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + (core.getActiveSpan as any).mockReturnValue({}); + (core.getRootSpan as any).mockReturnValue({ setAttributes: vi.fn() }); + + const instrumentation = createSentryServerInstrumentation(); + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/test', + instrument: mockInstrument, + }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.loader(mockCallLoader, { + request: { method: 'GET', url: 'http://example.com/test', headers: { get: () => null } }, + params: {}, + unstable_pattern: '/test', + context: undefined, + }); + + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { type: 'react_router.loader', handled: false }, + data: { + 'http.method': 'GET', + 'http.url': '/test', + }, + }); + }); + + it('should not capture errors when captureErrors is false', async () => { + const mockError = new Error('Test error'); + // React Router returns an error result, not a rejection + const mockCallLoader = vi.fn().mockResolvedValue({ status: 'error', error: mockError }); + const mockInstrument = vi.fn(); + + (core.startSpan as any).mockImplementation((_opts: any, fn: any) => fn()); + (core.getActiveSpan as any).mockReturnValue({}); + (core.getRootSpan as any).mockReturnValue({ setAttributes: vi.fn() }); + + const instrumentation = createSentryServerInstrumentation({ captureErrors: false }); + instrumentation.route?.({ + id: 'test-route', + index: false, + path: '/test', + instrument: mockInstrument, + }); + + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.loader(mockCallLoader, { + request: { method: 'GET', url: 'http://example.com/test', headers: { get: () => null } }, + params: {}, + unstable_pattern: '/test', + context: undefined, + }); + + expect(core.captureException).not.toHaveBeenCalled(); + }); +}); + +describe('isInstrumentationApiUsed', () => { + beforeEach(() => { + delete (globalThis as any).__sentryReactRouterServerInstrumentationUsed; + }); + + afterEach(() => { + delete (globalThis as any).__sentryReactRouterServerInstrumentationUsed; + }); + + it('should return false when flag is not set', () => { + expect(isInstrumentationApiUsed()).toBe(false); + }); + + it('should return true when flag is set', () => { + (globalThis as any).__sentryReactRouterServerInstrumentationUsed = true; + expect(isInstrumentationApiUsed()).toBe(true); + }); + + it('should return true after createSentryServerInstrumentation is called', () => { + expect(isInstrumentationApiUsed()).toBe(false); + createSentryServerInstrumentation(); + expect(isInstrumentationApiUsed()).toBe(true); + }); +}); diff --git a/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts b/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts index fb5141f8830d..93e0a91a1c2b 100644 --- a/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts +++ b/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts @@ -18,6 +18,7 @@ vi.mock('@sentry/core', async () => { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', startSpan: vi.fn((opts, fn) => fn({})), + GLOBAL_OBJ: {}, }; }); diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts index 45b4ca1062df..71875d1aa887 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -24,11 +24,16 @@ vi.mock('@sentry/core', () => ({ getRootSpan: vi.fn(), getTraceMetaTags: vi.fn(), flushIfServerless: vi.fn(), + updateSpanName: vi.fn(), + getCurrentScope: vi.fn(() => ({ setTransactionName: vi.fn() })), + GLOBAL_OBJ: globalThis, })); describe('wrapSentryHandleRequest', () => { beforeEach(() => { vi.clearAllMocks(); + // Reset global flag for unstable instrumentation + delete (globalThis as any).__sentryReactRouterServerInstrumentationUsed; }); test('should call original handler with same parameters', async () => { @@ -175,6 +180,39 @@ describe('wrapSentryHandleRequest', () => { mockError, ); }); + + test('should set route attributes as fallback when instrumentation API is used (for lazy-only routes)', async () => { + // Set the global flag indicating instrumentation API is in use + (globalThis as any).__sentryReactRouterServerInstrumentationUsed = true; + + const originalHandler = vi.fn().mockResolvedValue('test'); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const mockActiveSpan = {}; + const mockRootSpan = { setAttributes: vi.fn() }; + const mockRpcMetadata = { type: RPCType.HTTP, route: '/some-path' }; + + (getActiveSpan as unknown as ReturnType).mockReturnValue(mockActiveSpan); + (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); + const getRPCMetadata = vi.fn().mockReturnValue(mockRpcMetadata); + (vi.importActual('@opentelemetry/core') as unknown as { getRPCMetadata: typeof getRPCMetadata }).getRPCMetadata = + getRPCMetadata; + + const routerContext = { + staticHandlerContext: { + matches: [{ route: { path: 'some-path' } }], + }, + } as any; + + await wrappedHandler(new Request('https://nacho.queso'), 200, new Headers(), routerContext, {} as any); + + // Should set route attributes without origin (to preserve instrumentation_api origin) + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + [ATTR_HTTP_ROUTE]: '/some-path', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }); + expect(mockRpcMetadata.route).toBe('/some-path'); + }); }); describe('getMetaTagTransformer', () => { diff --git a/packages/react-router/test/server/wrapServerAction.test.ts b/packages/react-router/test/server/wrapServerAction.test.ts index 5eb92ef53b3b..9b01d229bc5a 100644 --- a/packages/react-router/test/server/wrapServerAction.test.ts +++ b/packages/react-router/test/server/wrapServerAction.test.ts @@ -1,6 +1,6 @@ import * as core from '@sentry/core'; import type { ActionFunctionArgs } from 'react-router'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { wrapServerAction } from '../../src/server/wrapServerAction'; vi.mock('@sentry/core', async () => { @@ -9,12 +9,21 @@ vi.mock('@sentry/core', async () => { ...actual, startSpan: vi.fn(), flushIfServerless: vi.fn(), + debug: { + warn: vi.fn(), + }, }; }); describe('wrapServerAction', () => { beforeEach(() => { vi.clearAllMocks(); + // Reset the global flag and warning state + delete (globalThis as any).__sentryReactRouterServerInstrumentationUsed; + }); + + afterEach(() => { + delete (globalThis as any).__sentryReactRouterServerInstrumentationUsed; }); it('should wrap an action function with default options', async () => { @@ -107,4 +116,36 @@ describe('wrapServerAction', () => { await expect(wrappedAction(mockArgs)).rejects.toBe(mockError); }); + + it('should skip span creation and warn when instrumentation API is used', async () => { + // Reset modules to get a fresh copy with unset warning flag + vi.resetModules(); + // @ts-expect-error - Dynamic import for module reset works at runtime but vitest's typecheck doesn't fully support it + const { wrapServerAction: freshWrapServerAction } = await import('../../src/server/wrapServerAction'); + + // Set the global flag indicating instrumentation API is in use + (globalThis as any).__sentryReactRouterServerInstrumentationUsed = true; + + const mockActionFn = vi.fn().mockResolvedValue('result'); + const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; + + const wrappedAction = freshWrapServerAction({}, mockActionFn); + + // Call multiple times + await wrappedAction(mockArgs); + await wrappedAction(mockArgs); + await wrappedAction(mockArgs); + + // Should warn about redundant wrapper via debug.warn, but only once + expect(core.debug.warn).toHaveBeenCalledTimes(1); + expect(core.debug.warn).toHaveBeenCalledWith( + expect.stringContaining('wrapServerAction is redundant when using the instrumentation API'), + ); + + // Should not create spans (instrumentation API handles it) + expect(core.startSpan).not.toHaveBeenCalled(); + + // Should still execute the action function + expect(mockActionFn).toHaveBeenCalledTimes(3); + }); }); diff --git a/packages/react-router/test/server/wrapServerLoader.test.ts b/packages/react-router/test/server/wrapServerLoader.test.ts index b375d9b4da51..c4491a301bf7 100644 --- a/packages/react-router/test/server/wrapServerLoader.test.ts +++ b/packages/react-router/test/server/wrapServerLoader.test.ts @@ -1,6 +1,6 @@ import * as core from '@sentry/core'; import type { LoaderFunctionArgs } from 'react-router'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { wrapServerLoader } from '../../src/server/wrapServerLoader'; vi.mock('@sentry/core', async () => { @@ -9,12 +9,21 @@ vi.mock('@sentry/core', async () => { ...actual, startSpan: vi.fn(), flushIfServerless: vi.fn(), + debug: { + warn: vi.fn(), + }, }; }); describe('wrapServerLoader', () => { beforeEach(() => { vi.clearAllMocks(); + // Reset the global flag and warning state + delete (globalThis as any).__sentryReactRouterServerInstrumentationUsed; + }); + + afterEach(() => { + delete (globalThis as any).__sentryReactRouterServerInstrumentationUsed; }); it('should wrap a loader function with default options', async () => { @@ -107,4 +116,36 @@ describe('wrapServerLoader', () => { await expect(wrappedLoader(mockArgs)).rejects.toBe(mockError); }); + + it('should skip span creation and warn when instrumentation API is used', async () => { + // Reset modules to get a fresh copy with unset warning flag + vi.resetModules(); + // @ts-expect-error - Dynamic import for module reset works at runtime but vitest's typecheck doesn't fully support it + const { wrapServerLoader: freshWrapServerLoader } = await import('../../src/server/wrapServerLoader'); + + // Set the global flag indicating instrumentation API is in use + (globalThis as any).__sentryReactRouterServerInstrumentationUsed = true; + + const mockLoaderFn = vi.fn().mockResolvedValue('result'); + const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; + + const wrappedLoader = freshWrapServerLoader({}, mockLoaderFn); + + // Call multiple times + await wrappedLoader(mockArgs); + await wrappedLoader(mockArgs); + await wrappedLoader(mockArgs); + + // Should warn about redundant wrapper via debug.warn, but only once + expect(core.debug.warn).toHaveBeenCalledTimes(1); + expect(core.debug.warn).toHaveBeenCalledWith( + expect.stringContaining('wrapServerLoader is redundant when using the instrumentation API'), + ); + + // Should not create spans (instrumentation API handles it) + expect(core.startSpan).not.toHaveBeenCalled(); + + // Should still execute the loader function + expect(mockLoaderFn).toHaveBeenCalledTimes(3); + }); }); From c1f6906a7843303d78afa248e032085e2f25ee83 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 19 Dec 2025 12:28:01 +0000 Subject: [PATCH 2/3] Move instrumentation API functions to serverGlobals not to break hydrogen --- .../src/server/createServerInstrumentation.ts | 19 ++++----------- .../src/server/instrumentation/reactRouter.ts | 2 +- .../server/integration/reactRouterServer.ts | 2 +- .../react-router/src/server/serverGlobals.ts | 23 +++++++++++++++++++ .../src/server/wrapSentryHandleRequest.ts | 2 +- .../src/server/wrapServerAction.ts | 2 +- .../src/server/wrapServerLoader.ts | 2 +- 7 files changed, 32 insertions(+), 20 deletions(-) create mode 100644 packages/react-router/src/server/serverGlobals.ts diff --git a/packages/react-router/src/server/createServerInstrumentation.ts b/packages/react-router/src/server/createServerInstrumentation.ts index 44a93980c04c..bbc17bdc14a4 100644 --- a/packages/react-router/src/server/createServerInstrumentation.ts +++ b/packages/react-router/src/server/createServerInstrumentation.ts @@ -7,7 +7,6 @@ import { getActiveSpan, getCurrentScope, getRootSpan, - GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -17,12 +16,10 @@ import { import { DEBUG_BUILD } from '../common/debug-build'; import type { InstrumentableRequestHandler, InstrumentableRoute, ServerInstrumentation } from '../common/types'; import { captureInstrumentationError, getPathFromRequest, getPattern, normalizeRoutePath } from '../common/utils'; +import { markInstrumentationApiUsed } from './serverGlobals'; -const SENTRY_SERVER_INSTRUMENTATION_FLAG = '__sentryReactRouterServerInstrumentationUsed'; - -type GlobalObjWithFlag = typeof GLOBAL_OBJ & { - [SENTRY_SERVER_INSTRUMENTATION_FLAG]?: boolean; -}; +// Re-export for backward compatibility and external use +export { isInstrumentationApiUsed } from './serverGlobals'; /** * Options for creating Sentry server instrumentation. @@ -44,7 +41,7 @@ export function createSentryServerInstrumentation( ): ServerInstrumentation { const { captureErrors = true } = options; - (GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_SERVER_INSTRUMENTATION_FLAG] = true; + markInstrumentationApiUsed(); DEBUG_BUILD && debug.log('React Router server instrumentation API enabled.'); return { @@ -199,14 +196,6 @@ export function createSentryServerInstrumentation( }; } -/** - * Check if React Router's instrumentation API is being used on the server. - * @experimental - */ -export function isInstrumentationApiUsed(): boolean { - return !!(GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_SERVER_INSTRUMENTATION_FLAG]; -} - function updateRootSpanWithRoute(method: string, pattern: string | undefined, urlPath: string): void { const activeSpan = getActiveSpan(); if (!activeSpan) return; diff --git a/packages/react-router/src/server/instrumentation/reactRouter.ts b/packages/react-router/src/server/instrumentation/reactRouter.ts index bfb9831dd568..2f24d2c7bcb7 100644 --- a/packages/react-router/src/server/instrumentation/reactRouter.ts +++ b/packages/react-router/src/server/instrumentation/reactRouter.ts @@ -15,7 +15,7 @@ import { } from '@sentry/core'; import type * as reactRouter from 'react-router'; import { DEBUG_BUILD } from '../../common/debug-build'; -import { isInstrumentationApiUsed } from '../createServerInstrumentation'; +import { isInstrumentationApiUsed } from '../serverGlobals'; import { getOpName, getSpanName, isDataRequest } from './util'; type ReactRouterModuleExports = typeof reactRouter; diff --git a/packages/react-router/src/server/integration/reactRouterServer.ts b/packages/react-router/src/server/integration/reactRouterServer.ts index 35dae9780b38..1789109facf3 100644 --- a/packages/react-router/src/server/integration/reactRouterServer.ts +++ b/packages/react-router/src/server/integration/reactRouterServer.ts @@ -1,8 +1,8 @@ import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import { defineIntegration } from '@sentry/core'; import { generateInstrumentOnce, NODE_VERSION } from '@sentry/node'; -import { isInstrumentationApiUsed } from '../createServerInstrumentation'; import { ReactRouterInstrumentation } from '../instrumentation/reactRouter'; +import { isInstrumentationApiUsed } from '../serverGlobals'; const INTEGRATION_NAME = 'ReactRouterServer'; diff --git a/packages/react-router/src/server/serverGlobals.ts b/packages/react-router/src/server/serverGlobals.ts new file mode 100644 index 000000000000..33f96ab5f45a --- /dev/null +++ b/packages/react-router/src/server/serverGlobals.ts @@ -0,0 +1,23 @@ +import { GLOBAL_OBJ } from '@sentry/core'; + +const SENTRY_SERVER_INSTRUMENTATION_FLAG = '__sentryReactRouterServerInstrumentationUsed'; + +type GlobalObjWithFlag = typeof GLOBAL_OBJ & { + [SENTRY_SERVER_INSTRUMENTATION_FLAG]?: boolean; +}; + +/** + * Mark that the React Router instrumentation API is being used on the server. + * @internal + */ +export function markInstrumentationApiUsed(): void { + (GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_SERVER_INSTRUMENTATION_FLAG] = true; +} + +/** + * Check if React Router's instrumentation API is being used on the server. + * @experimental + */ +export function isInstrumentationApiUsed(): boolean { + return !!(GLOBAL_OBJ as GlobalObjWithFlag)[SENTRY_SERVER_INSTRUMENTATION_FLAG]; +} diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 9e1adbc2c5ff..9bf634a68505 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -11,7 +11,7 @@ import { updateSpanName, } from '@sentry/core'; import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router'; -import { isInstrumentationApiUsed } from './createServerInstrumentation'; +import { isInstrumentationApiUsed } from './serverGlobals'; type OriginalHandleRequestWithoutMiddleware = ( request: Request, diff --git a/packages/react-router/src/server/wrapServerAction.ts b/packages/react-router/src/server/wrapServerAction.ts index 5a4b253e7e19..263bdddb62cd 100644 --- a/packages/react-router/src/server/wrapServerAction.ts +++ b/packages/react-router/src/server/wrapServerAction.ts @@ -14,7 +14,7 @@ import { } from '@sentry/core'; import type { ActionFunctionArgs } from 'react-router'; import { DEBUG_BUILD } from '../common/debug-build'; -import { isInstrumentationApiUsed } from './createServerInstrumentation'; +import { isInstrumentationApiUsed } from './serverGlobals'; type SpanOptions = { name?: string; diff --git a/packages/react-router/src/server/wrapServerLoader.ts b/packages/react-router/src/server/wrapServerLoader.ts index d9b0dc521743..34bb5a58aa1e 100644 --- a/packages/react-router/src/server/wrapServerLoader.ts +++ b/packages/react-router/src/server/wrapServerLoader.ts @@ -14,7 +14,7 @@ import { } from '@sentry/core'; import type { LoaderFunctionArgs } from 'react-router'; import { DEBUG_BUILD } from '../common/debug-build'; -import { isInstrumentationApiUsed } from './createServerInstrumentation'; +import { isInstrumentationApiUsed } from './serverGlobals'; type SpanOptions = { name?: string; From ad33e6ce1e4a7ab08b805ba3997c98c2c3ff4291 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Fri, 19 Dec 2025 14:02:44 +0000 Subject: [PATCH 3/3] Update hydrogen server transaction tests with better parameterization --- .../hydrogen-react-router-7/tests/server-transactions.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/server-transactions.test.ts index 0455ea2e0b79..1dca64548e83 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/server-transactions.test.ts @@ -13,7 +13,8 @@ test('Sends parameterized transaction name to Sentry', async ({ page }) => { const transaction = await transactionPromise; expect(transaction).toBeDefined(); - expect(transaction.transaction).toBe('GET /user/123'); + // Transaction name should be parameterized (route pattern, not actual URL) + expect(transaction.transaction).toBe('GET /user/:id'); }); test('Sends two linked transactions (server & client) to Sentry', async ({ page }) => {