diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/.gitignore new file mode 100644 index 000000000000..ebb991370034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/.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-dev/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/app.css new file mode 100644 index 000000000000..e78d2096ad20 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/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-dev/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/entry.client.tsx new file mode 100644 index 000000000000..97539b6fd7cd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/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. +// As of React Router 7.15+, HydratedRouter invokes the client instrumentation hooks (`navigate` +// and `fetch`) in Framework Mode, so navigation and fetcher spans are created via the +// instrumentation API (origin `auto.*.react_router.instrumentation_api`). The legacy +// instrumentHydratedRouter() subscribe still runs to parameterize navigation span names. +const sentryClientInstrumentation = [tracing.clientInstrumentation]; + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/entry.server.tsx new file mode 100644 index 000000000000..178a8ed4e377 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/entry.server.tsx @@ -0,0 +1,20 @@ +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 }); + +export const instrumentations = [Sentry.createSentryServerInstrumentation()]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/root.tsx new file mode 100644 index 000000000000..bc1b8f1236c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/root.tsx @@ -0,0 +1,67 @@ +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) { + 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-dev/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes.ts new file mode 100644 index 000000000000..48c31488de4a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes.ts @@ -0,0 +1,6 @@ +import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes'; + +export default [ + index('routes/home.tsx'), + ...prefix('performance', [route('with-middleware', 'routes/performance/with-middleware.tsx')]), +] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes/home.tsx new file mode 100644 index 000000000000..7812a9c500d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/app/routes/home.tsx @@ -0,0 +1,10 @@ +export function meta() { + 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-dev/app/routes/performance/with-middleware.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/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-dev/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-dev/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/instrument.mjs new file mode 100644 index 000000000000..bb1dad2e5da9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/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-dev/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/package.json new file mode 100644 index 000000000000..82ddbc00177f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/package.json @@ -0,0 +1,59 @@ +{ + "name": "react-router-7-framework-instrumentation-dev", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "@react-router/node": "^7", + "@react-router/serve": "^7", + "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", + "isbot": "^5.1.17", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@react-router/dev": "^7", + "@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": { + "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev --port 3030", + "proxy": "node start-event-proxy.mjs", + "typecheck": "react-router typegen && tsc", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "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-dev/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/playwright.config.mjs new file mode 100644 index 000000000000..c1926d884722 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/playwright.config.mjs @@ -0,0 +1,11 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +// This app runs the React Router dev server (`react-router dev`) instead of a production build, so +// we can verify that server build capture (used for middleware name resolution) works in dev mode. +const config = getPlaywrightConfig({ + // `react-router dev` runs the Vite dev server, which ignores PORT - pass the port to the script. + startCommand: `pnpm dev`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/react-router.config.ts new file mode 100644 index 000000000000..b5f863f6052a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/react-router.config.ts @@ -0,0 +1,8 @@ +import type { Config } from '@react-router/dev/config'; + +export default { + ssr: true, + future: { + v8_middleware: true, + }, +} satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/start-event-proxy.mjs new file mode 100644 index 000000000000..007f80b1aaf5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-framework-instrumentation-dev', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tests/constants.ts new file mode 100644 index 000000000000..347234254c25 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tests/constants.ts @@ -0,0 +1 @@ +export const APP_NAME = 'react-router-7-framework-instrumentation-dev'; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tests/performance/middleware.server.test.ts new file mode 100644 index 000000000000..38f6a014b4ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tests/performance/middleware.server.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('server - instrumentation API middleware (dev mode)', () => { + test('resolves the middleware name from the server build captured in dev mode', 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', + ); + + expect(middlewareSpan).toBeDefined(); + + // The middleware name is only available when the server build was captured. In dev mode this + // relies on the Vite plugin injecting the capture snippet for the SSR module (see + // makeServerBuildCapturePlugin). Without it, the span would fall back to `middleware `. + expect(middlewareSpan!.data?.['react_router.middleware.name']).toBe('authMiddleware'); + expect(middlewareSpan!.description).toBe('middleware authMiddleware'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/tsconfig.json new file mode 100644 index 000000000000..a16df276e8bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/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-dev/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/vite.config.ts new file mode 100644 index 000000000000..4da306d41cc7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation-dev/vite.config.ts @@ -0,0 +1,11 @@ +import { reactRouter } from '@react-router/dev/vite'; +import { sentryReactRouter } from '@sentry/react-router'; +import { defineConfig } from 'vite'; + +export default defineConfig(async config => ({ + plugins: [ + reactRouter(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...((await sentryReactRouter({ sourcemaps: { disable: true } }, config)) as any[]), + ], +})); diff --git a/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts b/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts index d95129a491ff..b22fc715d782 100644 --- a/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts +++ b/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts @@ -7,21 +7,15 @@ const SERVER_BUILD_MODULE_ID = 'virtual:react-router/server-build'; * A Sentry plugin for React Router to capture the server build for middleware name resolution. */ export function makeServerBuildCapturePlugin(): Plugin { - let isSsrBuild = false; - return { name: 'sentry-react-router-server-build-capture', enforce: 'post', - configResolved(config) { - isSsrBuild = !!config.build.ssr; - }, - - transform(code, id) { - // TODO: This only captures the server build for production SSR builds. Dev mode - // (`react-router dev`) is not covered yet, so middleware names may be missing there - this - // should be handled for dev too. - if (!isSsrBuild) { + transform(code, id, options) { + // Only inject into the server build module when transforming for the SSR environment. + // `options.ssr` is set for the SSR module in both dev (`react-router dev`) and production + // builds, so this covers both - unlike `config.build.ssr`, which is only true during a build. + if (!options?.ssr) { return null; } diff --git a/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts b/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts index f76090b60f56..ab77da695dc0 100644 --- a/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts +++ b/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts @@ -11,9 +11,16 @@ describe('makeServerBuildCapturePlugin', () => { expect(plugin.enforce).toBe('post'); }); - it('should return null for non-SSR builds', () => { + it('should return null for non-SSR transforms', () => { + const plugin = makeServerBuildCapturePlugin(); + + const result = (plugin as any).transform(SERVER_BUILD_CODE, SERVER_BUILD_MODULE_ID, { ssr: false }); + + expect(result).toBeNull(); + }); + + it('should return null when no options are passed', () => { const plugin = makeServerBuildCapturePlugin(); - (plugin as any).configResolved({ build: { ssr: false } }); const result = (plugin as any).transform(SERVER_BUILD_CODE, SERVER_BUILD_MODULE_ID); @@ -22,18 +29,18 @@ describe('makeServerBuildCapturePlugin', () => { it('should return null for non-server-build modules in SSR mode', () => { const plugin = makeServerBuildCapturePlugin(); - (plugin as any).configResolved({ build: { ssr: true } }); - const result = (plugin as any).transform('export function helper() {}', 'src/utils.ts'); + const result = (plugin as any).transform('export function helper() {}', 'src/utils.ts', { ssr: true }); expect(result).toBeNull(); }); - it('should inject capture snippet into the server build module in SSR mode', () => { + // `options.ssr` is true for the SSR module in both dev (`react-router dev`) and production builds, + // so a single assertion covers both cases - the plugin cannot (and need not) distinguish them. + it('should inject capture snippet into the server build module for SSR transforms', () => { const plugin = makeServerBuildCapturePlugin(); - (plugin as any).configResolved({ build: { ssr: true } }); - const result = (plugin as any).transform(SERVER_BUILD_CODE, SERVER_BUILD_MODULE_ID); + const result = (plugin as any).transform(SERVER_BUILD_CODE, SERVER_BUILD_MODULE_ID, { ssr: true }); expect(result).not.toBeNull(); expect(result.code).toContain(SERVER_BUILD_CODE);