diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.gitignore new file mode 100644 index 000000000000..8b44e4bde640 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.gitignore @@ -0,0 +1,11 @@ +node_modules + +/.cache +/build +.env +.react-router + +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.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-rsc/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/app.css new file mode 100644 index 000000000000..b31c3a9d0ddf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/app.css @@ -0,0 +1,6 @@ +html, +body { + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.server.tsx new file mode 100644 index 000000000000..738cd1515a4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/entry.server.tsx @@ -0,0 +1,18 @@ +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 }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/root.tsx new file mode 100644 index 000000000000..84fe28d1edf9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/root.tsx @@ -0,0 +1,58 @@ +import * as Sentry from '@sentry/react-router'; +import { Links, Meta, Outlet, ScrollRestoration, isRouteErrorResponse } from 'react-router'; +import type { Route } from './+types/root'; +import stylesheet from './app.css?url'; +import { SentryClient } from './sentry-client'; + +export const links: Route.LinksFunction = () => [{ 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-rsc/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes.ts new file mode 100644 index 000000000000..3230eb68a6dd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes.ts @@ -0,0 +1,24 @@ +import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes'; + +export default [ + index('routes/home.tsx'), + ...prefix('rsc', [ + // RSC Server Component tests + route('server-component', 'routes/rsc/server-component.tsx'), + route('server-component-error', 'routes/rsc/server-component-error.tsx'), + route('server-component-async', 'routes/rsc/server-component-async.tsx'), + route('server-component-redirect', 'routes/rsc/server-component-redirect.tsx'), + route('server-component-not-found', 'routes/rsc/server-component-not-found.tsx'), + route('server-component/:param', 'routes/rsc/server-component-param.tsx'), + route('server-component-comment-directive', 'routes/rsc/server-component-comment-directive.tsx'), + // RSC Server Function tests + route('server-function', 'routes/rsc/server-function.tsx'), + route('server-function-error', 'routes/rsc/server-function-error.tsx'), + route('server-function-arrow', 'routes/rsc/server-function-arrow.tsx'), + route('server-function-default', 'routes/rsc/server-function-default.tsx'), + ]), + ...prefix('performance', [ + index('routes/performance/index.tsx'), + route('with/:param', 'routes/performance/dynamic-param.tsx'), + ]), +] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/home.tsx new file mode 100644 index 000000000000..4b44ffca47d3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/home.tsx @@ -0,0 +1,34 @@ +import { Link } from 'react-router'; + +export default function Home() { + return ( +
+

React Router 7 RSC Test App

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/dynamic-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/dynamic-param.tsx new file mode 100644 index 000000000000..3cb7434f1272 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/dynamic-param.tsx @@ -0,0 +1,16 @@ +import { wrapServerComponent } from '@sentry/react-router'; +import type { Route } from './+types/dynamic-param'; + +function DynamicParamPage({ params }: Route.ComponentProps) { + return ( +
+

Dynamic Param Page

+

Param: {params.param}

+
+ ); +} + +export default wrapServerComponent(DynamicParamPage, { + componentRoute: '/performance/with/:param', + componentType: 'Page', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/index.tsx new file mode 100644 index 000000000000..6358c5ac11f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/index.tsx @@ -0,0 +1,22 @@ +import { wrapServerComponent } from '@sentry/react-router'; +import { Link } from 'react-router'; + +function PerformancePage() { + return ( +
+

Performance Test

+ +
+ ); +} + +export default wrapServerComponent(PerformancePage, { + componentRoute: '/performance', + componentType: 'Page', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions-default.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions-default.ts new file mode 100644 index 000000000000..5a02bfd5ddb6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions-default.ts @@ -0,0 +1,12 @@ +'use server'; + +// This file only has a default export — the Sentry plugin should wrap it as +// a default server function, NOT extract "defaultAction" as a named export. +export default async function defaultAction(formData: FormData): Promise<{ success: boolean; message: string }> { + const name = formData.get('name') as string; + await new Promise(resolve => setTimeout(resolve, 50)); + return { + success: true, + message: `Default: Hello, ${name}!`, + }; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions.ts new file mode 100644 index 000000000000..d73df0d21c37 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions.ts @@ -0,0 +1,25 @@ +'use server'; + +export async function submitForm(formData: FormData): Promise<{ success: boolean; message: string }> { + const name = formData.get('name') as string; + + await new Promise(resolve => setTimeout(resolve, 50)); + + return { + success: true, + message: `Hello, ${name}! Form submitted successfully.`, + }; +} + +export async function submitFormWithError(_formData: FormData): Promise<{ success: boolean; message: string }> { + throw new Error('RSC Server Function Error: Something went wrong!'); +} + +export const submitFormArrow = async (formData: FormData): Promise<{ success: boolean; message: string }> => { + const name = formData.get('name') as string; + await new Promise(resolve => setTimeout(resolve, 50)); + return { + success: true, + message: `Arrow: Hello, ${name}!`, + }; +}; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-async.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-async.tsx new file mode 100644 index 000000000000..0c17420ed514 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-async.tsx @@ -0,0 +1,25 @@ +import { wrapServerComponent } from '@sentry/react-router'; + +async function fetchData(): Promise<{ title: string; content: string }> { + await new Promise(resolve => setTimeout(resolve, 50)); + return { + title: 'Async Server Component', + content: 'This content was fetched asynchronously on the server.', + }; +} + +async function AsyncServerComponent() { + const data = await fetchData(); + + return ( +
+

{data.title}

+

{data.content}

+
+ ); +} + +export default wrapServerComponent(AsyncServerComponent, { + componentRoute: '/rsc/server-component-async', + componentType: 'Page', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-comment-directive.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-comment-directive.tsx new file mode 100644 index 000000000000..90cd2cf78851 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-comment-directive.tsx @@ -0,0 +1,25 @@ +// This is a server component, NOT a client component. +// "use client" — this comment should be ignored by the Sentry plugin. + +import { wrapServerComponent } from '@sentry/react-router'; +import type { Route } from './+types/server-component-comment-directive'; + +async function ServerComponentWithCommentDirective({ loaderData }: Route.ComponentProps) { + await new Promise(resolve => setTimeout(resolve, 10)); + + return ( +
+

Server Component With Comment Directive

+

Message: {loaderData?.message ?? 'No loader data'}

+
+ ); +} + +export default wrapServerComponent(ServerComponentWithCommentDirective, { + componentRoute: '/rsc/server-component-comment-directive', + componentType: 'Page', +}); + +export async function loader() { + return { message: 'Hello from comment-directive server component!' }; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-error.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-error.tsx new file mode 100644 index 000000000000..094b551fcfb0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-error.tsx @@ -0,0 +1,10 @@ +import { wrapServerComponent } from '@sentry/react-router'; + +async function ServerComponentWithError() { + throw new Error('RSC Server Component Error: Mamma mia!'); +} + +export default wrapServerComponent(ServerComponentWithError, { + componentRoute: '/rsc/server-component-error', + componentType: 'Page', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-not-found.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-not-found.tsx new file mode 100644 index 000000000000..98972fcaaa4b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-not-found.tsx @@ -0,0 +1,10 @@ +import { wrapServerComponent } from '@sentry/react-router'; + +async function NotFoundServerComponent() { + throw new Response('Not Found', { status: 404 }); +} + +export default wrapServerComponent(NotFoundServerComponent, { + componentRoute: '/rsc/server-component-not-found', + componentType: 'Page', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-param.tsx new file mode 100644 index 000000000000..c17927404c3d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-param.tsx @@ -0,0 +1,18 @@ +import { wrapServerComponent } from '@sentry/react-router'; +import type { Route } from './+types/server-component-param'; + +async function ParamServerComponent({ params }: Route.ComponentProps) { + await new Promise(resolve => setTimeout(resolve, 10)); + + return ( +
+

Server Component with Parameter

+

Parameter: {params.param}

+
+ ); +} + +export default wrapServerComponent(ParamServerComponent, { + componentRoute: '/rsc/server-component/:param', + componentType: 'Page', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-redirect.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-redirect.tsx new file mode 100644 index 000000000000..21389c6fece3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-redirect.tsx @@ -0,0 +1,11 @@ +import { redirect } from 'react-router'; +import { wrapServerComponent } from '@sentry/react-router'; + +async function RedirectServerComponent() { + throw redirect('/'); +} + +export default wrapServerComponent(RedirectServerComponent, { + componentRoute: '/rsc/server-component-redirect', + componentType: 'Page', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component.tsx new file mode 100644 index 000000000000..037d1876b35b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component.tsx @@ -0,0 +1,22 @@ +import { wrapServerComponent } from '@sentry/react-router'; +import type { Route } from './+types/server-component'; + +async function ServerComponent({ loaderData }: Route.ComponentProps) { + await new Promise(resolve => setTimeout(resolve, 10)); + + return ( +
+

Server Component

+

Message: {loaderData?.message ?? 'No loader data'}

+
+ ); +} + +export default wrapServerComponent(ServerComponent, { + componentRoute: '/rsc/server-component', + componentType: 'Page', +}); + +export async function loader() { + return { message: 'Hello from server loader!' }; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-arrow.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-arrow.tsx new file mode 100644 index 000000000000..1f899e2020dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-arrow.tsx @@ -0,0 +1,31 @@ +import { Form, useActionData } from 'react-router'; +import { submitFormArrow } from './actions'; +import type { Route } from './+types/server-function-arrow'; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData(); + return submitFormArrow(formData); +} + +export default function ServerFunctionArrowPage() { + const actionData = useActionData(); + + return ( +
+

Server Function Arrow Test

+
+ + + +
+ + {actionData && ( +
+

Message: {actionData.message}

+
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-default.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-default.tsx new file mode 100644 index 000000000000..b5c07b4a97ba --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-default.tsx @@ -0,0 +1,31 @@ +import { Form, useActionData } from 'react-router'; +import defaultAction from './actions-default'; +import type { Route } from './+types/server-function-default'; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData(); + return defaultAction(formData); +} + +export default function ServerFunctionDefaultPage() { + const actionData = useActionData(); + + return ( +
+

Server Function Default Export Test

+
+ + + +
+ + {actionData && ( +
+

Message: {actionData.message}

+
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-error.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-error.tsx new file mode 100644 index 000000000000..1769ed44471d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-error.tsx @@ -0,0 +1,30 @@ +import { Form, useActionData } from 'react-router'; +import { submitFormWithError } from './actions'; +import type { Route } from './+types/server-function-error'; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData(); + return submitFormWithError(formData); +} + +export default function ServerFunctionErrorPage() { + const actionData = useActionData(); + + return ( +
+

Server Function Error Test

+
+ + +
+ + {actionData && ( +
+

This should not appear - error should be thrown

+
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function.tsx new file mode 100644 index 000000000000..e0cea9b9905a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function.tsx @@ -0,0 +1,32 @@ +import { Form, useActionData } from 'react-router'; +import { submitForm } from './actions'; +import type { Route } from './+types/server-function'; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData(); + return submitForm(formData); +} + +export default function ServerFunctionPage() { + const actionData = useActionData(); + + return ( +
+

Server Function Test

+
+ + + +
+ + {actionData && ( +
+

Success: {String(actionData.success)}

+

Message: {actionData.message}

+
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/sentry-client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/sentry-client.tsx new file mode 100644 index 000000000000..968bc40e796c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/sentry-client.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useEffect } from 'react'; + +// RSC mode doesn't use entry.client.tsx, so we initialize Sentry via a client component. +export function SentryClient() { + useEffect(() => { + import('@sentry/react-router') + .then(Sentry => { + if (!Sentry.isInitialized()) { + Sentry.init({ + environment: 'qa', + dsn: 'https://username@domain/123', + tunnel: `http://localhost:3031/`, + integrations: [Sentry.reactRouterTracingIntegration()], + tracesSampleRate: 1.0, + tracePropagationTargets: [/^\//], + }); + } + }) + .catch(() => undefined); + }, []); + + return null; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/instrument.mjs new file mode 100644 index 000000000000..c16240141b6d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/react-router'; + +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-rsc/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/package.json new file mode 100644 index 000000000000..38fa37890510 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/package.json @@ -0,0 +1,63 @@ +{ + "name": "react-router-7-rsc", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "react": "19.1.0", + "react-dom": "19.1.0", + "react-router": "7.12.0", + "@react-router/node": "7.12.0", + "@react-router/serve": "7.12.0", + "@sentry/react-router": "latest || *", + "isbot": "^5.1.17" + }, + "devDependencies": { + "@types/react": "19.1.0", + "@types/react-dom": "19.1.0", + "@types/node": "^22", + "@react-router/dev": "7.12.0", + "@vitejs/plugin-rsc": "0.5.14", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "5.6.3", + "vite": "6.3.5" + }, + "scripts": { + "build": "react-router build", + "test:build-latest": "pnpm install && pnpm add react-router@latest && pnpm add @react-router/node@latest && pnpm add @react-router/serve@latest && pnpm add @react-router/dev@latest && pnpm add @vitejs/plugin-rsc@latest && pnpm 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" + }, + "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, + "optionalVariants": [ + { + "build-command": "pnpm test:build-latest", + "label": "react-router-7-rsc (latest)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/playwright.config.mjs new file mode 100644 index 000000000000..3ed5721107a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/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-rsc/public/.gitkeep b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/public/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/react-router.config.ts new file mode 100644 index 000000000000..51e8967770b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/react-router.config.ts @@ -0,0 +1,5 @@ +import type { Config } from '@react-router/dev/config'; + +export default { + ssr: true, +} satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/start-event-proxy.mjs new file mode 100644 index 000000000000..c39b3e59484b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-rsc', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/constants.ts new file mode 100644 index 000000000000..e0ecda948342 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/constants.ts @@ -0,0 +1 @@ +export const APP_NAME = 'react-router-7-rsc'; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/performance/performance.server.test.ts new file mode 100644 index 000000000000..682c61735b8f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/performance/performance.server.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('RSC - Performance', () => { + test('should send server transaction on pageload', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + return transactionEvent.transaction === 'GET /performance'; + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: 'GET /performance', + platform: 'node', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.rsc', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.rsc', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction_info: { source: 'route' }, + 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', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with/:param'; + }); + + await page.goto(`/performance/with/some-param`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: 'GET /performance/with/:param', + platform: 'node', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.rsc', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.rsc', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction_info: { source: 'route' }, + request: { + url: expect.stringContaining('/performance/with/some-param'), + 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', + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-component.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-component.test.ts new file mode 100644 index 000000000000..563884f3e676 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-component.test.ts @@ -0,0 +1,191 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('RSC - Server Component Wrapper', () => { + test('captures error from server component', async ({ page }) => { + const errorMessage = 'RSC Server Component Error: Mamma mia!'; + const errorPromise = waitForError(APP_NAME, errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/rsc/server-component-error`); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'react_router.rsc', + data: { + function: 'ServerComponent', + component_route: '/rsc/server-component-error', + component_type: 'Page', + }, + }, + }, + ], + }, + level: 'error', + platform: 'node', + environment: 'qa', + sdk: { + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'node' }, + }); + }); + + test('server component page loads with loader data', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const matchesRoute = + transactionEvent.transaction?.includes('/rsc/server-component') || + transactionEvent.request?.url?.includes('/rsc/server-component'); + return Boolean(isServerTransaction && matchesRoute); + }); + + await page.goto(`/rsc/server-component`); + + await expect(page.getByTestId('loader-message')).toContainText('Hello from server loader!'); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: expect.stringContaining('/rsc/server-component'), + platform: 'node', + environment: 'qa', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); + + test('async server component page loads', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const matchesRoute = + transactionEvent.transaction?.includes('/rsc/server-component-async') || + transactionEvent.request?.url?.includes('/rsc/server-component-async'); + return Boolean(isServerTransaction && matchesRoute); + }); + + await page.goto(`/rsc/server-component-async`); + + await expect(page.getByTestId('title')).toHaveText('Async Server Component'); + await expect(page.getByTestId('content')).toHaveText('This content was fetched asynchronously on the server.'); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: expect.stringContaining('/rsc/server-component-async'), + platform: 'node', + environment: 'qa', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); + + test('does not capture redirect as an error', async ({ page }) => { + const errorPromise = waitForError(APP_NAME, errorEvent => { + return !!errorEvent?.request?.url?.includes('/rsc/server-component-redirect'); + }); + + await page.goto('/rsc/server-component-redirect'); + + // The redirect should have taken us to the home page + await expect(page).toHaveURL('/'); + + // No error should be captured for a redirect + const maybeError = await Promise.race([ + errorPromise, + new Promise<'no-error'>(resolve => setTimeout(() => resolve('no-error'), 3000)), + ]); + + expect(maybeError).toBe('no-error'); + }); + + test('does not capture 404 response as an error', async ({ page }) => { + const errorPromise = waitForError(APP_NAME, errorEvent => { + return !!errorEvent?.request?.url?.includes('/rsc/server-component-not-found'); + }); + + await page.goto('/rsc/server-component-not-found'); + + // No error should be captured for a 404 response + const maybeError = await Promise.race([ + errorPromise, + new Promise<'no-error'>(resolve => setTimeout(() => resolve('no-error'), 3000)), + ]); + + expect(maybeError).toBe('no-error'); + }); + + test('manually wrapped server component with "use client" in comment loads correctly', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const matchesRoute = + transactionEvent.transaction?.includes('/rsc/server-component-comment-directive') || + transactionEvent.request?.url?.includes('/rsc/server-component-comment-directive'); + return Boolean(isServerTransaction && matchesRoute); + }); + + await page.goto('/rsc/server-component-comment-directive'); + + await expect(page.getByTestId('title')).toHaveText('Server Component With Comment Directive'); + await expect(page.getByTestId('loader-message')).toContainText('Hello from comment-directive server component!'); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: expect.stringContaining('/rsc/server-component-comment-directive'), + platform: 'node', + environment: 'qa', + }); + }); + + test('parameterized server component route works', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const matchesRoute = + transactionEvent.transaction?.includes('/rsc/server-component') || + transactionEvent.request?.url?.includes('/rsc/server-component/my-test-param'); + return Boolean(isServerTransaction && matchesRoute); + }); + + await page.goto(`/rsc/server-component/my-test-param`); + + await expect(page.getByTestId('param')).toContainText('my-test-param'); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + transaction: expect.stringContaining('/rsc/server-component'), + platform: 'node', + environment: 'qa', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-function.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-function.test.ts new file mode 100644 index 000000000000..dffc2cd47935 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-function.test.ts @@ -0,0 +1,209 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('RSC - Server Function Wrapper', () => { + test('creates transaction for wrapped server function via action', async ({ page }) => { + await page.goto(`/rsc/server-function`); + + // Listen after page load to skip the initial GET transaction. + // Match either a child span or a forceTransaction with the server function attribute. + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + // Match a transaction that either: + // (a) has a child span with the server function attribute, or + // (b) is the server function transaction itself (forceTransaction case) + const hasServerFunctionSpan = transactionEvent.spans?.some( + span => span.data?.['rsc.server_function.name'] === 'submitForm', + ); + const isServerFunctionTransaction = + transactionEvent.contexts?.trace?.data?.['rsc.server_function.name'] === 'submitForm'; + return Boolean(isServerTransaction && (hasServerFunctionSpan || isServerFunctionTransaction)); + }); + + await page.locator('#submit').click(); + + // Verify the form submission was successful + await expect(page.getByTestId('message')).toContainText('Hello, Sentry User!'); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + type: 'transaction', + platform: 'node', + environment: 'qa', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + sdk: { + 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) }, + ], + }, + }); + + // The server function span may be a child span or the transaction root itself. + const serverFunctionSpan = transaction.spans?.find( + span => span.data?.['rsc.server_function.name'] === 'submitForm', + ); + const traceData = transaction.contexts?.trace?.data; + + if (serverFunctionSpan) { + // Child span case: server function ran inside an active HTTP transaction + expect(serverFunctionSpan).toMatchObject({ + data: expect.objectContaining({ + 'sentry.op': 'function.rsc.server_function', + 'sentry.origin': 'auto.function.react_router.rsc.server_function', + 'rsc.server_function.name': 'submitForm', + }), + }); + } else { + // forceTransaction case: server function is the transaction + expect(traceData).toMatchObject( + expect.objectContaining({ + 'sentry.op': 'function.rsc.server_function', + 'rsc.server_function.name': 'submitForm', + }), + ); + } + }); + + test('captures error from wrapped server function', async ({ page }) => { + const errorMessage = 'RSC Server Function Error: Something went wrong!'; + const errorPromise = waitForError(APP_NAME, errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/rsc/server-function-error`); + await page.locator('#submit').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'react_router.rsc', + data: { + function: 'serverFunction', + server_function_name: 'submitFormWithError', + }, + }, + }, + ], + }, + level: 'error', + platform: 'node', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'node' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }); + }); + + test('creates transaction for server function using export const arrow pattern', async ({ page }) => { + // Load the page first to avoid catching the GET page load transaction. + await page.goto('/rsc/server-function-arrow'); + + // Set up listener after page load — filter for the server function span specifically. + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const hasServerFunctionSpan = transactionEvent.spans?.some( + span => span.data?.['rsc.server_function.name'] === 'submitFormArrow', + ); + const isServerFunctionTransaction = + transactionEvent.contexts?.trace?.data?.['rsc.server_function.name'] === 'submitFormArrow'; + return Boolean(isServerTransaction && (hasServerFunctionSpan || isServerFunctionTransaction)); + }); + + await page.locator('#submit').click(); + + await expect(page.getByTestId('message')).toContainText('Arrow: Hello, Arrow User!'); + + const transaction = await txPromise; + + const serverFunctionSpan = transaction.spans?.find( + span => span.data?.['rsc.server_function.name'] === 'submitFormArrow', + ); + + if (serverFunctionSpan) { + expect(serverFunctionSpan).toMatchObject({ + data: expect.objectContaining({ + 'sentry.op': 'function.rsc.server_function', + 'rsc.server_function.name': 'submitFormArrow', + }), + }); + } else { + // forceTransaction case: server function is the transaction + expect(transaction.contexts?.trace?.data).toMatchObject( + expect.objectContaining({ + 'sentry.op': 'function.rsc.server_function', + 'rsc.server_function.name': 'submitFormArrow', + }), + ); + } + }); + + test('creates transaction for server function with default export only', async ({ page }) => { + // Load the page first to avoid catching the GET page load transaction. + await page.goto('/rsc/server-function-default'); + + // Set up listener after page load — filter for the server function span specifically. + const txPromise = waitForTransaction(APP_NAME, transactionEvent => { + const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node'; + const hasServerFunctionSpan = transactionEvent.spans?.some( + span => span.data?.['rsc.server_function.name'] === 'default', + ); + const isServerFunctionTransaction = + transactionEvent.contexts?.trace?.data?.['rsc.server_function.name'] === 'default'; + return Boolean(isServerTransaction && (hasServerFunctionSpan || isServerFunctionTransaction)); + }); + + await page.locator('#submit').click(); + + await expect(page.getByTestId('message')).toContainText('Default: Hello, Default User!'); + + const transaction = await txPromise; + + // The default export should be wrapped as "default", not as "defaultAction" + const serverFunctionSpan = transaction.spans?.find(span => span.data?.['rsc.server_function.name'] === 'default'); + + if (serverFunctionSpan) { + expect(serverFunctionSpan).toMatchObject({ + data: expect.objectContaining({ + 'sentry.op': 'function.rsc.server_function', + 'rsc.server_function.name': 'default', + }), + }); + } else { + // forceTransaction case: server function is the transaction + expect(transaction.contexts?.trace?.data).toMatchObject( + expect.objectContaining({ + 'sentry.op': 'function.rsc.server_function', + 'rsc.server_function.name': 'default', + }), + ); + } + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tsconfig.json new file mode 100644 index 000000000000..b548d90d57ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["@react-router/node", "vite/client"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "noEmit": true, + "rootDirs": [".", ".react-router/types"] + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-rsc/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/vite.config.ts new file mode 100644 index 000000000000..a3a196935896 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/vite.config.ts @@ -0,0 +1,23 @@ +import { sentryReactRouter } from '@sentry/react-router'; +import { unstable_reactRouterRSC } from '@react-router/dev/vite'; +import rsc from '@vitejs/plugin-rsc/plugin'; +import { defineConfig } from 'vite'; + +export default defineConfig(async env => ({ + plugins: [ + ...(await sentryReactRouter({ experimental_rscAutoInstrumentation: { enabled: true } }, env)), + unstable_reactRouterRSC(), + rsc(), + ], + optimizeDeps: { + exclude: ['chokidar'], + }, + ssr: { + external: ['chokidar'], + }, + build: { + rollupOptions: { + external: ['chokidar'], + }, + }, +})); diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 8e9161841208..e14803c41815 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -45,6 +45,7 @@ "access": "public" }, "dependencies": { + "@babel/parser": "7.26.9", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.5.1", "@opentelemetry/instrumentation": "^0.211.0", @@ -55,7 +56,8 @@ "@sentry/node": "10.40.0", "@sentry/react": "10.40.0", "@sentry/vite-plugin": "^5.1.0", - "glob": "^13.0.6" + "glob": "^13.0.6", + "recast": "0.23.11" }, "devDependencies": { "@react-router/dev": "^7.13.0", diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index 6734b21c8583..27aa87e4ed97 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -1,6 +1,8 @@ // import/export got a false positive, and affects most of our index barrel files // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ +import type { ServerComponentContext, WrapServerFunctionOptions } from '../server/rsc/types'; + export * from '@sentry/browser'; export { init } from './sdk'; @@ -12,6 +14,29 @@ export { export { captureReactException, reactErrorHandler, Profiler, withProfiler, useProfiler } from '@sentry/react'; +/** + * Just a passthrough in case this is imported from the client. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function wrapServerComponent any>( + serverComponent: T, + _context: ServerComponentContext, +): T { + return serverComponent; +} + +/** + * Just a passthrough in case this is imported from the client. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function wrapServerFunction Promise>( + _functionName: string, + serverFunction: T, + _options?: WrapServerFunctionOptions, +): T { + return serverFunction; +} + /** * @deprecated ErrorBoundary is deprecated, use React Router's error boundary instead. * See https://docs.sentry.io/platforms/javascript/guides/react-router/#report-errors-from-error-boundaries diff --git a/packages/react-router/src/index.types.ts b/packages/react-router/src/index.types.ts index c9c5cb371763..ee09fc108b10 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -28,3 +28,6 @@ export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegra export declare const OpenFeatureIntegrationHook: typeof clientSdk.OpenFeatureIntegrationHook; export declare const statsigIntegration: typeof clientSdk.statsigIntegration; export declare const unleashIntegration: typeof clientSdk.unleashIntegration; + +export declare const wrapServerComponent: typeof serverSdk.wrapServerComponent; +export declare const wrapServerFunction: typeof serverSdk.wrapServerFunction; diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index e0b8c8981632..f09d8a25eccd 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -18,3 +18,8 @@ export { isInstrumentationApiUsed, type CreateSentryServerInstrumentationOptions, } from './createServerInstrumentation'; + +// React Server Components (RSC) - React Router v7.9.0+ +export { wrapServerFunction, wrapServerComponent } from './rsc'; + +export type { ServerComponentContext, WrapServerFunctionOptions } from './rsc'; diff --git a/packages/react-router/src/server/rsc/index.ts b/packages/react-router/src/server/rsc/index.ts new file mode 100644 index 000000000000..12d38de4ccbf --- /dev/null +++ b/packages/react-router/src/server/rsc/index.ts @@ -0,0 +1,4 @@ +export { wrapServerFunction } from './wrapServerFunction'; +export { wrapServerComponent } from './wrapServerComponent'; + +export type { ServerComponentContext, WrapServerFunctionOptions } from './types'; diff --git a/packages/react-router/src/server/rsc/responseUtils.ts b/packages/react-router/src/server/rsc/responseUtils.ts new file mode 100644 index 000000000000..fbc4d945de8f --- /dev/null +++ b/packages/react-router/src/server/rsc/responseUtils.ts @@ -0,0 +1,48 @@ +/** + * Check if an error/response is a redirect. + * Handles both Response objects and internal React Router throwables. + */ +export function isRedirectResponse(error: unknown): boolean { + if (error instanceof Response) { + const status = error.status; + return status >= 300 && status < 400; + } + + if (error && typeof error === 'object') { + const errorObj = error as { status?: number; type?: unknown }; + + if (typeof errorObj.type === 'string' && errorObj.type === 'redirect') { + return true; + } + + if (typeof errorObj.status === 'number' && errorObj.status >= 300 && errorObj.status < 400) { + return true; + } + } + + return false; +} + +/** + * Check if an error/response is a not-found response (404). + * Handles both Response objects and internal React Router throwables. + */ +export function isNotFoundResponse(error: unknown): boolean { + if (error instanceof Response) { + return error.status === 404; + } + + if (error && typeof error === 'object') { + const errorObj = error as { status?: number; type?: unknown }; + + if (typeof errorObj.type === 'string' && (errorObj.type === 'not-found' || errorObj.type === 'notFound')) { + return true; + } + + if (typeof errorObj.status === 'number' && errorObj.status === 404) { + return true; + } + } + + return false; +} diff --git a/packages/react-router/src/server/rsc/types.ts b/packages/react-router/src/server/rsc/types.ts new file mode 100644 index 000000000000..b1fc6598709b --- /dev/null +++ b/packages/react-router/src/server/rsc/types.ts @@ -0,0 +1,11 @@ +export interface ServerComponentContext { + /** The parameterized route path (e.g., "/users/:id") */ + componentRoute: string; + componentType: 'Page' | 'Layout' | 'Error' | 'Unknown'; +} + +export interface WrapServerFunctionOptions { + /** Custom span name. Defaults to `serverFunction/{functionName}` */ + name?: string; + attributes?: Record; +} diff --git a/packages/react-router/src/server/rsc/wrapServerComponent.ts b/packages/react-router/src/server/rsc/wrapServerComponent.ts new file mode 100644 index 000000000000..1504b22390d1 --- /dev/null +++ b/packages/react-router/src/server/rsc/wrapServerComponent.ts @@ -0,0 +1,138 @@ +import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; +import { + captureException, + debug, + flushIfServerless, + getActiveSpan, + getCurrentScope, + getIsolationScope, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + updateSpanName, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../common/debug-build'; +import { isNotFoundResponse, isRedirectResponse } from './responseUtils'; +import type { ServerComponentContext } from './types'; + +/** + * Wraps a server component with Sentry error instrumentation. + * + * @experimental + * + * @example + * ```ts + * import { wrapServerComponent } from "@sentry/react-router"; + * + * async function UserPage({ params }: Route.ComponentProps) { + * const user = await getUser(params.id); + * return ; + * } + * + * export default wrapServerComponent(UserPage, { + * componentRoute: "/users/:id", + * componentType: "Page", + * }); + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function wrapServerComponent any>( + serverComponent: T, + context: ServerComponentContext, +): T { + const { componentRoute, componentType } = context; + + DEBUG_BUILD && debug.log(`[RSC] Wrapping server component: ${componentType} (${componentRoute})`); + + return new Proxy(serverComponent, { + apply: (originalFunction, thisArg, args) => { + // No span — runs within the HTTP request span + const isolationScope = getIsolationScope(); + + const transactionName = `${componentType} Server Component (${componentRoute})`; + isolationScope.setTransactionName(transactionName); + + // In RSC mode, wrapSentryHandleRequest is never called (React Router bypasses entry.server.tsx), + // so we parameterize the HTTP span here from the component's route context. + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + if (rootSpan && componentRoute) { + const routeName = componentRoute.startsWith('/') ? componentRoute : `/${componentRoute}`; + // Server components are only rendered during GET requests (page loads). + // Server functions (POST) go through wrapServerFunction instead. + const httpTransactionName = `GET ${routeName}`; + updateSpanName(rootSpan, httpTransactionName); + getCurrentScope().setTransactionName(httpTransactionName); + rootSpan.setAttributes({ + [ATTR_HTTP_ROUTE]: routeName, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.rsc', + }); + } + + let result: ReturnType; + try { + result = originalFunction.apply(thisArg, args); + } catch (error) { + handleError(error, componentRoute, componentType); + flushIfServerless().catch(() => undefined); + throw error; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + if (result && typeof (result as any).then === 'function') { + // Side-effect handlers — return the original thenable so React sees the unmodified rejection + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + (result as any).then( + () => { + flushIfServerless().catch(() => undefined); + }, + (error: unknown) => { + handleError(error, componentRoute, componentType); + flushIfServerless().catch(() => undefined); + }, + ); + } else { + flushIfServerless().catch(() => undefined); + } + + return result; + }, + }); +} + +function handleError(error: unknown, componentRoute: string, componentType: string): void { + const span = getActiveSpan(); + + if (isRedirectResponse(error)) { + if (span) { + span.setStatus({ code: SPAN_STATUS_OK }); + } + return; + } + + if (isNotFoundResponse(error)) { + if (span) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + } + return; + } + + if (span) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + } + + captureException(error, { + mechanism: { + type: 'react_router.rsc', + handled: false, + data: { + function: 'ServerComponent', + component_route: componentRoute, + component_type: componentType, + }, + }, + }); +} diff --git a/packages/react-router/src/server/rsc/wrapServerFunction.ts b/packages/react-router/src/server/rsc/wrapServerFunction.ts new file mode 100644 index 000000000000..729e80646c56 --- /dev/null +++ b/packages/react-router/src/server/rsc/wrapServerFunction.ts @@ -0,0 +1,114 @@ +import { + captureException, + debug, + flushIfServerless, + getActiveSpan, + getIsolationScope, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startSpanManual, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../common/debug-build'; +import { isNotFoundResponse, isRedirectResponse } from './responseUtils'; +import type { WrapServerFunctionOptions } from './types'; + +/** + * Wraps a server function (marked with `"use server"` directive) with Sentry error and performance instrumentation. + * + * @experimental + * + * @example + * ```ts + * // actions.ts + * "use server"; + * import { wrapServerFunction } from "@sentry/react-router"; + * + * async function _updateUser(formData: FormData) { + * const userId = formData.get("id"); + * await db.users.update(userId, { name: formData.get("name") }); + * return { success: true }; + * } + * + * export const updateUser = wrapServerFunction("updateUser", _updateUser); + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function wrapServerFunction Promise>( + functionName: string, + serverFunction: T, + options: WrapServerFunctionOptions = {}, +): T { + // Auto-instrumentation wraps all exports from "use server" files, including non-function values. + if (typeof serverFunction !== 'function') { + DEBUG_BUILD && debug.warn(`[RSC] Not wrapping non-function export: ${functionName}`); + return serverFunction; + } + + DEBUG_BUILD && debug.log(`[RSC] Wrapping server function: ${functionName}`); + + return new Proxy(serverFunction, { + apply: (originalFunction, thisArg, args) => { + const spanName = options.name || `serverFunction/${functionName}`; + + const isolationScope = getIsolationScope(); + isolationScope.setTransactionName(spanName); + + const hasActiveSpan = !!getActiveSpan(); + + // startSpanManual is used instead of startSpan because startSpan's error handler + // would overwrite the intentional SPAN_STATUS_OK set for redirect responses. + return startSpanManual( + { + name: spanName, + forceTransaction: !hasActiveSpan, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.server_function', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.rsc.server_function', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'rsc.server_function.name': functionName, + ...options.attributes, + }, + }, + async span => { + try { + const result = await originalFunction.apply(thisArg, args); + span.end(); + return result; + } catch (error) { + if (isRedirectResponse(error)) { + span.setStatus({ code: SPAN_STATUS_OK }); + span.end(); + throw error; + } + + if (isNotFoundResponse(error)) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + span.end(); + throw error; + } + + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + + captureException(error, { + mechanism: { + type: 'react_router.rsc', + handled: false, + data: { + function: 'serverFunction', + server_function_name: functionName, + }, + }, + }); + span.end(); + throw error; + } finally { + await flushIfServerless(); + } + }, + ); + }, + }); +} diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 9bf634a68505..6735d0c5fcad 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -2,6 +2,7 @@ import { context } from '@opentelemetry/api'; import { getRPCMetadata, RPCType } from '@opentelemetry/core'; import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import { + escapeStringForRegex, flushIfServerless, getActiveSpan, getCurrentScope, @@ -66,12 +67,16 @@ export function wrapSentryHandleRequest( const parameterizedPath = routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path; + // When staticHandlerContext.matches doesn't provide a route, + // fall back to matching the request URL against the route manifest. + const resolvedPath = parameterizedPath ?? matchUrlToManifestRoute(request.url, routerContext); + const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - if (parameterizedPath && rootSpan) { + if (resolvedPath && rootSpan) { // Normalize route name - avoid "//" for root routes - const routeName = parameterizedPath.startsWith('/') ? parameterizedPath : `/${parameterizedPath}`; + const routeName = resolvedPath.startsWith('/') ? resolvedPath : `/${resolvedPath}`; // The express instrumentation writes on the rpcMetadata and that ends up stomping on the `http.route` attribute. const rpcMetadata = getRPCMetadata(context.active()); @@ -141,3 +146,107 @@ export function wrapSentryHandleRequest( // todo(v11): remove this /** @deprecated Use `wrapSentryHandleRequest` instead. */ export const sentryHandleRequest = wrapSentryHandleRequest; + +/** + * Resolves the full parameterized path for a route by walking up the parent chain. + */ +function resolveFullRoutePath( + routeId: string, + routes: Record, +): string | undefined { + const parts: string[] = []; + // Guard against circular parentId references in corrupted route manifests + const seen = new Set(); + let currentId: string | undefined = routeId; + while (currentId) { + if (seen.has(currentId)) break; + seen.add(currentId); + const route: { path?: string; parentId?: string } | undefined = routes[currentId]; + if (!route) break; + if (route.path) { + parts.unshift(route.path); + } + currentId = route.parentId; + } + if (parts.length === 0) return undefined; + const joined = parts.join('/'); + return joined.startsWith('/') ? joined : `/${joined}`; +} + +const PARAM_RE = /^:[\w-]+$/; +const STATIC_SEGMENT_SCORE = 10; +const DYNAMIC_SEGMENT_SCORE = 3; +const EMPTY_SEGMENT_SCORE = 1; +const SPLAT_PENALTY = -2; + +/** + * Computes a specificity score for a route pattern. + * Matches React Router's computeScore() algorithm. + */ +function computeScore(pattern: string): number { + const segments = pattern.split('/'); + let score = segments.length; + if (segments.includes('*')) { + score += SPLAT_PENALTY; + } + for (const segment of segments) { + if (segment === '*') continue; + else if (PARAM_RE.test(segment)) score += DYNAMIC_SEGMENT_SCORE; + else if (segment === '') score += EMPTY_SEGMENT_SCORE; + else score += STATIC_SEGMENT_SCORE; + } + return score; +} + +/** + * Matches a request URL against the route manifest to find the parameterized route path. + * Used as a fallback when staticHandlerContext.matches is empty. + */ +function matchUrlToManifestRoute( + requestUrl: string, + routerContext: { + manifest?: { routes?: Record }; + }, +): string | undefined { + const routes = routerContext?.manifest?.routes; + if (!routes) return undefined; + + let pathname: string; + try { + pathname = new URL(requestUrl).pathname; + } catch { + return undefined; + } + + // Strip trailing slash for consistent matching (e.g. /rsc/server-component/ → /rsc/server-component) + if (pathname.length > 1 && pathname.endsWith('/')) { + pathname = pathname.slice(0, -1); + } + + const routePaths: string[] = []; + for (const id of Object.keys(routes)) { + const fullPath = resolveFullRoutePath(id, routes); + if (fullPath) { + routePaths.push(fullPath); + } + } + + routePaths.sort((a, b) => computeScore(b) - computeScore(a)); + + for (const fullPath of routePaths) { + const segments = fullPath.split('/'); + const regexSegments = segments.map(seg => { + if (seg === '*') return '.*'; + if (seg.startsWith(':')) return '[^/]+'; + return escapeStringForRegex(seg); + }); + const hasWildcard = segments.includes('*'); + const regexStr = `^${regexSegments.join('/')}${hasWildcard ? '' : '$'}`; + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- route patterns from manifest, not user input + if (new RegExp(regexStr).test(pathname)) { + return fullPath; + } + } + + return undefined; +} diff --git a/packages/react-router/src/vite/index.ts b/packages/react-router/src/vite/index.ts index 5f5b6266015a..b34713e29dd1 100644 --- a/packages/react-router/src/vite/index.ts +++ b/packages/react-router/src/vite/index.ts @@ -1,4 +1,4 @@ export { sentryReactRouter } from './plugin'; export { sentryOnBuildEnd } from './buildEnd/handleOnBuildEnd'; -export type { SentryReactRouterBuildOptions } from './types'; +export type { AutoInstrumentRSCOptions, SentryReactRouterBuildOptions } from './types'; export { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin'; diff --git a/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts new file mode 100644 index 000000000000..915a658ba90b --- /dev/null +++ b/packages/react-router/src/vite/makeAutoInstrumentRSCPlugin.ts @@ -0,0 +1,307 @@ +import { readFile } from 'node:fs/promises'; +import * as recast from 'recast'; +import type { Plugin } from 'vite'; +import { parser } from './recastTypescriptParser'; +import type { AutoInstrumentRSCOptions } from './types'; + +import t = recast.types.namedTypes; + +type AutoInstrumentRSCPluginOptions = AutoInstrumentRSCOptions & { debug?: boolean }; + +const JS_EXTENSIONS_RE = /\.(ts|tsx|js|jsx|mjs|mts)$/; +const JS_IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; +const WRAPPED_MODULE_SUFFIX = '?sentry-rsc-wrap'; + +// Prevents the Sentry bundler plugin from transforming this import path +const SENTRY_PACKAGE = '@sentry/react-router'; + +/** Exported for testing. */ +export interface ModuleAnalysis { + hasUseServerDirective: boolean; + hasDefaultExport: boolean; + hasManualServerFunctionWrapping: boolean; + namedExports: string[]; +} + +// Babel-specific extensions not present in recast's type definitions +interface BabelExpressionStatement extends t.ExpressionStatement { + directive?: string; +} +interface BabelExportNamedDeclaration extends t.ExportNamedDeclaration { + exportKind?: string; +} +interface BabelExportSpecifier extends t.ExportSpecifier { + exportKind?: string; +} + +/** Extracts directive values ("use server") from the program. */ +function extractDirectives(program: t.Program): { useServer: boolean } { + let useServer = false; + + // Babel puts directives in program.directives (e.g. "use strict", "use server") + if (program.directives) { + for (const d of program.directives) { + const value = d.value?.value; + if (value === 'use server') { + useServer = true; + } + } + } + + // Some Babel versions may place directive-like expression statements in the body + for (const node of program.body) { + if (node.type !== 'ExpressionStatement') { + break; + } + const expr = node as BabelExpressionStatement; + let value = expr.directive; + if (!value && expr.expression.type === 'StringLiteral') { + value = expr.expression.value; + } + if (typeof value !== 'string') { + break; + } + if (value === 'use server') { + useServer = true; + } + } + + return { useServer }; +} + +/** + * Collects named export identifiers from an ExportNamedDeclaration node. + * Returns `true` when the node contains `export { x as default }`, which + * counts as a default export even though it is syntactically an + * ExportNamedDeclaration. + */ +function collectNamedExports(node: BabelExportNamedDeclaration, into: Set): boolean { + if (node.exportKind === 'type') { + return false; + } + + let hasDefault = false; + + const decl = node.declaration; + if (decl) { + if (decl.type === 'TSTypeAliasDeclaration' || decl.type === 'TSInterfaceDeclaration') { + return false; + } + + if (decl.type === 'VariableDeclaration') { + for (const declarator of decl.declarations) { + if (declarator.type === 'VariableDeclarator' && declarator.id.type === 'Identifier') { + into.add(declarator.id.name); + } + } + } else { + const name = getDeclarationName(decl); + if (name) { + into.add(name); + } + } + } + + if (node.specifiers) { + node.specifiers + .filter(spec => spec.type === 'ExportSpecifier' && (spec as BabelExportSpecifier).exportKind !== 'type') + .forEach(spec => { + const name = getExportedName(spec.exported as t.Identifier | t.StringLiteral); + if (name === 'default') { + hasDefault = true; + } else if (name) { + into.add(name); + } + }); + } + + return hasDefault; +} + +function getExportedName(node: t.Identifier | t.StringLiteral): string | undefined { + if (node.type === 'Identifier') { + return node.name; + } + if (node.type === 'StringLiteral') { + return node.value; + } + return undefined; +} + +function getDeclarationName(decl: t.Declaration): string | undefined { + if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') { + const id = (decl as t.FunctionDeclaration | t.ClassDeclaration).id; + return id?.type === 'Identifier' ? id.name : undefined; + } + return undefined; +} + +function importsWrapServerFunction(node: t.ImportDeclaration): boolean { + if (node.source.value !== SENTRY_PACKAGE || !node.specifiers) { + return false; + } + return node.specifiers.some( + spec => + spec.type === 'ImportSpecifier' && + spec.imported.type === 'Identifier' && + spec.imported.name === 'wrapServerFunction', + ); +} + +/** + * AST-based analysis of a module's directives, exports, and Sentry wrapping. + * Uses recast + @babel/parser so that patterns inside comments or strings + * are never matched. + * + * Returns `null` when the file cannot be parsed (the caller should skip it). + * + * Exported for testing. + */ +export function analyzeModule(code: string): ModuleAnalysis | null { + let program: t.Program | undefined; + try { + const ast = recast.parse(code, { parser }); + program = (ast as { program?: t.Program }).program; + } catch { + return null; + } + + if (!program) { + return null; + } + + const directives = extractDirectives(program); + + let hasDefaultExport = false; + let hasManualServerFunctionWrapping = false; + const namedExportSet = new Set(); + + recast.visit(program, { + visitExportDefaultDeclaration() { + hasDefaultExport = true; + return false; + }, + visitExportNamedDeclaration(path) { + if (collectNamedExports(path.node as BabelExportNamedDeclaration, namedExportSet)) { + hasDefaultExport = true; + } + return false; + }, + visitImportDeclaration(path) { + if (importsWrapServerFunction(path.node)) { + hasManualServerFunctionWrapping = true; + } + return false; + }, + }); + + return { + hasUseServerDirective: directives.useServer, + hasDefaultExport, + hasManualServerFunctionWrapping, + namedExports: [...namedExportSet], + }; +} + +/** Exported for testing. */ +export function getServerFunctionWrapperCode( + originalId: string, + exportNames: string[], + includeDefault: boolean = false, +): string { + // Vite may add query strings (e.g. ?v=abc) to module IDs — strip them so + // the wrapped re-import points at a clean filesystem path. + const cleanId = originalId.split('?')[0] ?? originalId; + const wrappedId = JSON.stringify(`${cleanId}${WRAPPED_MODULE_SUFFIX}`); + const lines = [ + "'use server';", + `import { wrapServerFunction } from '${SENTRY_PACKAGE}';`, + `import * as _sentry_original from ${wrappedId};`, + ...exportNames.map( + name => + `export const ${name} = wrapServerFunction(${JSON.stringify(name)}, _sentry_original[${JSON.stringify(name)}]);`, + ), + ]; + if (includeDefault) { + lines.push('export default wrapServerFunction("default", _sentry_original.default);'); + } + return lines.join('\n'); +} + +/** @experimental May change in minor releases. */ +export function makeAutoInstrumentRSCPlugin(options: AutoInstrumentRSCPluginOptions = {}): Plugin { + const { enabled = true, debug = false } = options; + + let rscDetected = false; + + return { + name: 'sentry-react-router-rsc-auto-instrument', + enforce: 'pre', + + configResolved(config) { + rscDetected = config.plugins.some(p => p.name.startsWith('react-router/rsc')); + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] RSC mode ${rscDetected ? 'detected' : 'not detected'}`); + }, + + resolveId(source) { + return source.includes(WRAPPED_MODULE_SUFFIX) ? source : null; + }, + + async load(id: string) { + if (!id.includes(WRAPPED_MODULE_SUFFIX)) { + return null; + } + const idWithoutSuffix = id.slice(0, -WRAPPED_MODULE_SUFFIX.length); + const originalPath = idWithoutSuffix.split('?')[0] ?? idWithoutSuffix; + try { + return await readFile(originalPath, 'utf-8'); + } catch { + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Failed to read original file: ${originalPath}`); + return null; + } + }, + + transform(code: string, id: string) { + if (id.includes(WRAPPED_MODULE_SUFFIX)) { + return null; + } + if (!enabled || !rscDetected || !JS_EXTENSIONS_RE.test(id)) { + return null; + } + + const analysis = analyzeModule(code); + if (!analysis) { + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Skipping unparseable: ${id}`); + return null; + } + + // Only handle "use server" files — server components must be wrapped manually + if (!analysis.hasUseServerDirective) { + return null; + } + + if (analysis.hasManualServerFunctionWrapping) { + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Skipping already wrapped: ${id}`); + return null; + } + + // Skip string literal export names (e.g. `export { fn as "my-action" }`) that + // can't be used in `export const name = ...` generated code. + const exportNames = analysis.namedExports.filter(name => JS_IDENTIFIER_RE.test(name)); + const includeDefault = analysis.hasDefaultExport; + if (exportNames.length === 0 && !includeDefault) { + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Skipping server function file with no exports: ${id}`); + return null; + } + const exportList = includeDefault ? [...exportNames, 'default'] : exportNames; + // eslint-disable-next-line no-console + debug && console.log(`[Sentry RSC] Auto-wrapping server functions: ${id} -> [${exportList.join(', ')}]`); + return { code: getServerFunctionWrapperCode(id, exportNames, includeDefault), map: null }; + }, + }; +} diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts index 4a66a2575987..a436aa5cd456 100644 --- a/packages/react-router/src/vite/plugin.ts +++ b/packages/react-router/src/vite/plugin.ts @@ -1,4 +1,5 @@ import type { ConfigEnv, Plugin } from 'vite'; +import { makeAutoInstrumentRSCPlugin } from './makeAutoInstrumentRSCPlugin'; import { makeConfigInjectorPlugin } from './makeConfigInjectorPlugin'; import { makeCustomSentryVitePlugins } from './makeCustomSentryVitePlugins'; import { makeEnableSourceMapsPlugin } from './makeEnableSourceMapsPlugin'; @@ -21,6 +22,10 @@ export async function sentryReactRouter( plugins.push(makeConfigInjectorPlugin(options)); plugins.push(makeServerBuildCapturePlugin()); + if (options.experimental_rscAutoInstrumentation?.enabled === true) { + plugins.push(makeAutoInstrumentRSCPlugin({ ...options.experimental_rscAutoInstrumentation, debug: options.debug })); + } + if (process.env.NODE_ENV !== 'development' && viteConfig.command === 'build' && viteConfig.mode !== 'development') { plugins.push(makeEnableSourceMapsPlugin(options)); plugins.push(...(await makeCustomSentryVitePlugins(options))); diff --git a/packages/react-router/src/vite/recastTypescriptParser.ts b/packages/react-router/src/vite/recastTypescriptParser.ts new file mode 100644 index 000000000000..274f877ab20a --- /dev/null +++ b/packages/react-router/src/vite/recastTypescriptParser.ts @@ -0,0 +1,91 @@ +// This babel parser config is taken from recast's typescript parser config, specifically from these two files: +// see: https://github.com/benjamn/recast/blob/master/parsers/_babel_options.ts +// see: https://github.com/benjamn/recast/blob/master/parsers/babel-ts.ts +// +// Changes: +// - we add the 'jsx' plugin, because React Router files use JSX syntax +// - minor import and export changes +// - merged the two files linked above into one for simplicity + +// Date of access: 2025-03-04 +// Commit: https://github.com/benjamn/recast/commit/ba5132174894b496285da9d001f1f2524ceaed3a + +// Recast license: + +// Copyright (c) 2012 Ben Newman + +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: + +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import type { ParserPlugin } from '@babel/parser'; +import { parse as babelParse } from '@babel/parser'; +import type { Options } from 'recast'; + +export const parser: Options['parser'] = { + parse: (source: string) => + babelParse(source, { + strictMode: false, + allowImportExportEverywhere: true, + allowReturnOutsideFunction: true, + startLine: 1, + tokens: true, + plugins: [ + 'jsx', + 'typescript', + 'asyncGenerators', + 'bigInt', + 'classPrivateMethods', + 'classPrivateProperties', + 'classProperties', + 'classStaticBlock', + 'decimal', + 'decorators-legacy', + 'doExpressions', + 'dynamicImport', + 'exportDefaultFrom', + 'exportNamespaceFrom', + 'functionBind', + 'functionSent', + 'importAssertions', + 'exportExtensions' as ParserPlugin, + 'importMeta', + 'nullishCoalescingOperator', + 'numericSeparator', + 'objectRestSpread', + 'optionalCatchBinding', + 'optionalChaining', + [ + 'pipelineOperator', + { + proposal: 'minimal', + }, + ], + [ + 'recordAndTuple', + { + syntaxType: 'hash', + }, + ], + 'throwExpressions', + 'topLevelAwait', + 'v8intrinsic', + ], + sourceType: 'module', + }), +}; diff --git a/packages/react-router/src/vite/types.ts b/packages/react-router/src/vite/types.ts index c7555630c4fa..d0e47332bbed 100644 --- a/packages/react-router/src/vite/types.ts +++ b/packages/react-router/src/vite/types.ts @@ -74,4 +74,24 @@ export type SentryReactRouterBuildOptions = BuildTimeOptionsBase & */ sourceMapsUploadOptions?: SourceMapsOptions; // todo(v11): Remove this option (all options already exist in BuildTimeOptionsBase) + + /** + * @experimental Options for automatic RSC server function instrumentation. + * Set `{ enabled: true }` to activate. RSC mode requires `unstable_reactRouterRSC()` in the Vite config. + * Server components must be wrapped manually using `wrapServerComponent`. + */ + experimental_rscAutoInstrumentation?: AutoInstrumentRSCOptions; }; + +/** + * Options for the experimental RSC auto-instrumentation Vite plugin. + * + * Set `{ enabled: true }` to opt in. Auto-instrumentation is off by default. + */ +export type AutoInstrumentRSCOptions = { + /** + * Enable or disable auto-instrumentation of server functions. + * @default false + */ + enabled?: boolean; +}; diff --git a/packages/react-router/test/server/rsc/responseUtils.test.ts b/packages/react-router/test/server/rsc/responseUtils.test.ts new file mode 100644 index 000000000000..b46ff633ae18 --- /dev/null +++ b/packages/react-router/test/server/rsc/responseUtils.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { isNotFoundResponse, isRedirectResponse } from '../../../src/server/rsc/responseUtils'; + +describe('responseUtils', () => { + describe('isRedirectResponse', () => { + it.each([301, 302, 303, 307, 308])('should return true for Response with %d status', status => { + expect(isRedirectResponse(new Response(null, { status }))).toBe(true); + }); + + it.each([200, 404, 500])('should return false for Response with %d status', status => { + expect(isRedirectResponse(new Response(null, { status }))).toBe(false); + }); + + it('should return true for object with redirect type', () => { + expect(isRedirectResponse({ type: 'redirect', url: '/new-path' })).toBe(true); + }); + + it('should return true for object with status in 3xx range', () => { + expect(isRedirectResponse({ status: 302, location: '/new-path' })).toBe(true); + }); + + it.each([null, undefined, 'error', 42, new Error('test')])( + 'should return false for non-object value: %p', + value => { + expect(isRedirectResponse(value)).toBe(false); + }, + ); + }); + + describe('isNotFoundResponse', () => { + it('should return true for Response with 404 status', () => { + expect(isNotFoundResponse(new Response(null, { status: 404 }))).toBe(true); + }); + + it.each([200, 302, 500])('should return false for Response with %d status', status => { + expect(isNotFoundResponse(new Response(null, { status }))).toBe(false); + }); + + it('should return true for object with not-found or notFound type', () => { + expect(isNotFoundResponse({ type: 'not-found' })).toBe(true); + expect(isNotFoundResponse({ type: 'notFound' })).toBe(true); + }); + + it('should return true for object with status 404', () => { + expect(isNotFoundResponse({ status: 404 })).toBe(true); + }); + + it.each([null, undefined, 'error', 42])('should return false for non-object value: %p', value => { + expect(isNotFoundResponse(value)).toBe(false); + }); + }); +}); diff --git a/packages/react-router/test/server/rsc/wrapServerComponent.test.ts b/packages/react-router/test/server/rsc/wrapServerComponent.test.ts new file mode 100644 index 000000000000..95621b0b4a6e --- /dev/null +++ b/packages/react-router/test/server/rsc/wrapServerComponent.test.ts @@ -0,0 +1,311 @@ +import * as core from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapServerComponent } from '../../../src/server/rsc/wrapServerComponent'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + getIsolationScope: vi.fn(), + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + getCurrentScope: vi.fn(), + updateSpanName: vi.fn(), + captureException: vi.fn(), + flushIfServerless: vi.fn().mockResolvedValue(undefined), + }; +}); + +describe('wrapServerComponent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should wrap a server component and execute it', () => { + const mockResult = { type: 'div' }; + const mockComponent = vi.fn().mockReturnValue(mockResult); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/users/:id', + componentType: 'Page', + }); + const result = wrappedComponent({ id: '123' }); + + expect(result).toEqual(mockResult); + expect(mockComponent).toHaveBeenCalledWith({ id: '123' }); + expect(mockSetTransactionName).toHaveBeenCalledWith('Page Server Component (/users/:id)'); + }); + + it('should parameterize the root HTTP span with the component route', () => { + const mockComponent = vi.fn().mockReturnValue({ type: 'div' }); + const mockSetTransactionName = vi.fn(); + const mockScopeSetTransactionName = vi.fn(); + const mockRootSpan = { setAttributes: vi.fn() }; + const mockActiveSpan = {}; + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue(mockActiveSpan); + (core.getRootSpan as any).mockReturnValue(mockRootSpan); + (core.getCurrentScope as any).mockReturnValue({ + setTransactionName: mockScopeSetTransactionName, + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/users/:id', + componentType: 'Page', + }); + wrappedComponent({ id: '123' }); + + expect(core.updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /users/:id'); + expect(mockScopeSetTransactionName).toHaveBeenCalledWith('GET /users/:id'); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + 'http.route': '/users/:id', + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.react_router.rsc', + }); + }); + + it('should normalize route without leading slash when parameterizing root span', () => { + const mockComponent = vi.fn().mockReturnValue({ type: 'div' }); + const mockSetTransactionName = vi.fn(); + const mockScopeSetTransactionName = vi.fn(); + const mockRootSpan = { setAttributes: vi.fn() }; + const mockActiveSpan = {}; + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue(mockActiveSpan); + (core.getRootSpan as any).mockReturnValue(mockRootSpan); + (core.getCurrentScope as any).mockReturnValue({ + setTransactionName: mockScopeSetTransactionName, + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: 'users/:id', + componentType: 'Page', + }); + wrappedComponent({ id: '123' }); + + expect(core.updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /users/:id'); + expect(mockScopeSetTransactionName).toHaveBeenCalledWith('GET /users/:id'); + }); + + it('should not parameterize root span when no active span exists', () => { + const mockComponent = vi.fn().mockReturnValue({ type: 'div' }); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue(undefined); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/users/:id', + componentType: 'Page', + }); + wrappedComponent({ id: '123' }); + + expect(core.updateSpanName).not.toHaveBeenCalled(); + }); + + it('should capture exceptions on sync error', () => { + const mockError = new Error('Component render failed'); + const mockComponent = vi.fn().mockImplementation(() => { + throw mockError; + }); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/users/:id', + componentType: 'Page', + }); + + expect(() => wrappedComponent()).toThrow('Component render failed'); + expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_ERROR, message: 'internal_error' }); + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { + type: 'react_router.rsc', + handled: false, + data: { + function: 'ServerComponent', + component_route: '/users/:id', + component_type: 'Page', + }, + }, + }); + }); + + it('should capture exceptions on async rejection', async () => { + const mockError = new Error('Async component failed'); + const mockComponent = vi.fn().mockRejectedValue(mockError); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/async-page', + componentType: 'Page', + }); + + const promise = wrappedComponent(); + await expect(promise).rejects.toThrow('Async component failed'); + + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { + type: 'react_router.rsc', + handled: false, + data: { + function: 'ServerComponent', + component_route: '/async-page', + component_type: 'Page', + }, + }, + }); + }); + + it('should not capture redirect responses as errors', () => { + const redirectResponse = new Response(null, { + status: 302, + headers: { Location: '/new-path' }, + }); + const mockComponent = vi.fn().mockImplementation(() => { + throw redirectResponse; + }); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/users/:id', + componentType: 'Page', + }); + + expect(() => wrappedComponent()).toThrow(); + expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_OK }); + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should not capture 404 responses as errors but mark span status', () => { + const notFoundResponse = new Response(null, { status: 404 }); + const mockComponent = vi.fn().mockImplementation(() => { + throw notFoundResponse; + }); + const mockSetStatus = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue({ setStatus: mockSetStatus }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/users/:id', + componentType: 'Page', + }); + + expect(() => wrappedComponent()).toThrow(); + expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_ERROR, message: 'not_found' }); + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should work with async server components', async () => { + const mockResult = { type: 'div', props: { children: 'async content' } }; + const mockComponent = vi.fn().mockResolvedValue(mockResult); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/async-page', + componentType: 'Page', + }); + const result = await wrappedComponent(); + + expect(result).toEqual(mockResult); + expect(mockSetTransactionName).toHaveBeenCalledWith('Page Server Component (/async-page)'); + }); + + it('should flush on completion for async components', async () => { + const mockResult = { type: 'div' }; + const mockComponent = vi.fn().mockResolvedValue(mockResult); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/async-page', + componentType: 'Page', + }); + const result = wrappedComponent(); + + await result; + // Allow microtask queue to flush + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should flush on completion for sync components', () => { + const mockComponent = vi.fn().mockReturnValue({ type: 'div' }); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/page', + componentType: 'Page', + }); + wrappedComponent(); + + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should handle span being undefined', () => { + const mockError = new Error('Component error'); + const mockComponent = vi.fn().mockImplementation(() => { + throw mockError; + }); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + (core.getActiveSpan as any).mockReturnValue(undefined); + + const wrappedComponent = wrapServerComponent(mockComponent, { + componentRoute: '/page', + componentType: 'Page', + }); + + expect(() => wrappedComponent()).toThrow('Component error'); + expect(core.captureException).toHaveBeenCalled(); + }); +}); diff --git a/packages/react-router/test/server/rsc/wrapServerFunction.test.ts b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts new file mode 100644 index 000000000000..83cf39ae6739 --- /dev/null +++ b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts @@ -0,0 +1,213 @@ +import * as core from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapServerFunction } from '../../../src/server/rsc/wrapServerFunction'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startSpanManual: vi.fn(), + getIsolationScope: vi.fn(), + captureException: vi.fn(), + flushIfServerless: vi.fn().mockResolvedValue(undefined), + getActiveSpan: vi.fn(), + }; +}); + +describe('wrapServerFunction', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should wrap a server function and execute it', async () => { + const mockResult = { success: true }; + const mockServerFn = vi.fn().mockResolvedValue(mockResult); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpanManual as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), end: vi.fn() })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + const result = await wrappedFn('arg1', 'arg2'); + + expect(result).toEqual(mockResult); + expect(mockServerFn).toHaveBeenCalledWith('arg1', 'arg2'); + expect(core.getIsolationScope).toHaveBeenCalled(); + expect(mockSetTransactionName).toHaveBeenCalledWith('serverFunction/testFunction'); + expect(core.startSpanManual).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'serverFunction/testFunction', + forceTransaction: true, + attributes: expect.objectContaining({ + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.server_function', + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.react_router.rsc.server_function', + 'rsc.server_function.name': 'testFunction', + }), + }), + expect.any(Function), + ); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should set forceTransaction to false when there is an active span', async () => { + const mockResult = { success: true }; + const mockServerFn = vi.fn().mockResolvedValue(mockResult); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.getActiveSpan as any).mockReturnValue({ spanId: 'existing-span' }); + (core.startSpanManual as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), end: vi.fn() })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + await wrappedFn(); + + expect(core.startSpanManual).toHaveBeenCalledWith( + expect.objectContaining({ + forceTransaction: false, + }), + expect.any(Function), + ); + }); + + it('should use custom span name when provided', async () => { + const mockServerFn = vi.fn().mockResolvedValue('result'); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpanManual as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), end: vi.fn() })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn, { + name: 'Custom Span Name', + }); + await wrappedFn(); + + expect(mockSetTransactionName).toHaveBeenCalledWith('Custom Span Name'); + expect(core.startSpanManual).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Custom Span Name', + }), + expect.any(Function), + ); + }); + + it('should merge custom attributes with default attributes', async () => { + const mockServerFn = vi.fn().mockResolvedValue('result'); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpanManual as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), end: vi.fn() })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn, { + attributes: { 'custom.attr': 'value' }, + }); + await wrappedFn(); + + expect(core.startSpanManual).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.rsc.server_function', + 'custom.attr': 'value', + }), + }), + expect.any(Function), + ); + }); + + it('should capture exceptions on error', async () => { + const mockError = new Error('Server function failed'); + const mockServerFn = vi.fn().mockRejectedValue(mockError); + const mockSetStatus = vi.fn(); + const mockEnd = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpanManual as any).mockImplementation((_: any, fn: any) => + fn({ setStatus: mockSetStatus, end: mockEnd }), + ); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + + await expect(wrappedFn()).rejects.toThrow('Server function failed'); + expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_ERROR, message: 'internal_error' }); + expect(core.captureException).toHaveBeenCalledWith(mockError, { + mechanism: { + type: 'react_router.rsc', + handled: false, + data: { + function: 'serverFunction', + server_function_name: 'testFunction', + }, + }, + }); + expect(mockEnd).toHaveBeenCalled(); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should not capture redirect errors as exceptions', async () => { + const redirectResponse = new Response(null, { + status: 302, + headers: { Location: '/new-path' }, + }); + const mockServerFn = vi.fn().mockRejectedValue(redirectResponse); + const mockSetStatus = vi.fn(); + const mockEnd = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpanManual as any).mockImplementation((_: any, fn: any) => + fn({ setStatus: mockSetStatus, end: mockEnd }), + ); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + + await expect(wrappedFn()).rejects.toBe(redirectResponse); + expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_OK }); + expect(mockEnd).toHaveBeenCalled(); + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should not capture not-found errors as exceptions', async () => { + const notFoundResponse = new Response(null, { status: 404 }); + const mockServerFn = vi.fn().mockRejectedValue(notFoundResponse); + const mockSetStatus = vi.fn(); + const mockEnd = vi.fn(); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpanManual as any).mockImplementation((_: any, fn: any) => + fn({ setStatus: mockSetStatus, end: mockEnd }), + ); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + + await expect(wrappedFn()).rejects.toBe(notFoundResponse); + expect(mockSetStatus).toHaveBeenCalledWith({ code: core.SPAN_STATUS_ERROR, message: 'not_found' }); + expect(mockEnd).toHaveBeenCalled(); + expect(core.captureException).not.toHaveBeenCalled(); + }); + + it('should propagate errors after capturing', async () => { + const mockError = new Error('Test error'); + const mockServerFn = vi.fn().mockRejectedValue(mockError); + const mockSetTransactionName = vi.fn(); + + (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName }); + (core.startSpanManual as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn(), end: vi.fn() })); + + const wrappedFn = wrapServerFunction('testFunction', mockServerFn); + + await expect(wrappedFn()).rejects.toBe(mockError); + }); + + describe('non-function exports', () => { + it('should return non-function values unchanged', () => { + expect(wrapServerFunction('foo', 3 as any)).toBe(3); + expect(wrapServerFunction('foo', null as any)).toBe(null); + }); + + it('should return object as-is', () => { + const obj = { key: 'value' }; + expect(wrapServerFunction('foo', obj as any)).toBe(obj); + }); + }); +}); diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts index 71875d1aa887..e98f6a37920e 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -4,10 +4,12 @@ import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import { flushIfServerless, getActiveSpan, + getCurrentScope, getRootSpan, getTraceMetaTags, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + updateSpanName, } from '@sentry/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer'; @@ -17,17 +19,21 @@ vi.mock('@opentelemetry/core', () => ({ RPCType: { HTTP: 'http' }, getRPCMetadata: vi.fn(), })); -vi.mock('@sentry/core', () => ({ - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', - getActiveSpan: vi.fn(), - getRootSpan: vi.fn(), - getTraceMetaTags: vi.fn(), - flushIfServerless: vi.fn(), - updateSpanName: vi.fn(), - getCurrentScope: vi.fn(() => ({ setTransactionName: vi.fn() })), - GLOBAL_OBJ: globalThis, -})); +vi.mock('@sentry/core', async importOriginal => { + const actual = await importOriginal>(); + return { + ...actual, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + getTraceMetaTags: vi.fn(), + flushIfServerless: vi.fn(), + updateSpanName: vi.fn(), + getCurrentScope: vi.fn(() => ({ setTransactionName: vi.fn() })), + GLOBAL_OBJ: globalThis, + }; +}); describe('wrapSentryHandleRequest', () => { beforeEach(() => { @@ -36,6 +42,23 @@ describe('wrapSentryHandleRequest', () => { delete (globalThis as any).__sentryReactRouterServerInstrumentationUsed; }); + function setupManifestTest() { + const originalHandler = vi.fn().mockResolvedValue('test'); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const mockActiveSpan = {}; + const mockRootSpan = { setAttributes: vi.fn() }; + const mockSetTransactionName = vi.fn(); + + (getActiveSpan as unknown as ReturnType).mockReturnValue(mockActiveSpan); + (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); + (getCurrentScope as unknown as ReturnType).mockReturnValue({ + setTransactionName: mockSetTransactionName, + }); + + return { wrappedHandler, mockRootSpan, mockSetTransactionName }; + } + test('should call original handler with same parameters', async () => { const originalHandler = vi.fn().mockResolvedValue('original response'); const wrappedHandler = wrapSentryHandleRequest(originalHandler); @@ -181,6 +204,181 @@ describe('wrapSentryHandleRequest', () => { ); }); + test('should use manifest routes when staticHandlerContext.matches is empty', async () => { + const { wrappedHandler, mockRootSpan, mockSetTransactionName } = setupManifestTest(); + + const routerContext = { + staticHandlerContext: { + matches: [], + }, + manifest: { + routes: { + root: { path: undefined, parentId: undefined }, + 'rsc-layout': { path: 'rsc', parentId: 'root' }, + 'rsc-server-component': { path: 'server-component', parentId: 'rsc-layout' }, + }, + }, + } as any; + + await wrappedHandler( + new Request('https://example.com/rsc/server-component'), + 200, + new Headers(), + routerContext, + {} as any, + ); + + expect(updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /rsc/server-component'); + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /rsc/server-component'); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [ATTR_HTTP_ROUTE]: '/rsc/server-component', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }), + ); + }); + + test('should handle parameterized routes from manifest', async () => { + const { wrappedHandler, mockRootSpan, mockSetTransactionName } = setupManifestTest(); + + const routerContext = { + staticHandlerContext: { + matches: [], + }, + manifest: { + routes: { + root: { path: undefined, parentId: undefined }, + performance: { path: 'performance', parentId: 'root' }, + 'performance-param': { path: 'with/:param', parentId: 'performance' }, + }, + }, + } as any; + + await wrappedHandler( + new Request('https://example.com/performance/with/some-param'), + 200, + new Headers(), + routerContext, + {} as any, + ); + + expect(updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /performance/with/:param'); + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /performance/with/:param'); + }); + + test('should match routes with dots in path segments', async () => { + const { wrappedHandler, mockRootSpan, mockSetTransactionName } = setupManifestTest(); + + const routerContext = { + staticHandlerContext: { + matches: [], + }, + manifest: { + routes: { + root: { path: undefined, parentId: undefined }, + 'api-v1': { path: 'api', parentId: 'root' }, + 'api-v1-users': { path: 'v1.0/users', parentId: 'api-v1' }, + }, + }, + } as any; + + // /api/v1.0/users should match (dot is literal) + await wrappedHandler( + new Request('https://example.com/api/v1.0/users'), + 200, + new Headers(), + routerContext, + {} as any, + ); + + expect(updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /api/v1.0/users'); + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /api/v1.0/users'); + + vi.clearAllMocks(); + const { wrappedHandler: wrappedHandlerNoMatch } = setupManifestTest(); + + // /api/v1X0/users should not match (would incorrectly match with unescaped .) + await wrappedHandlerNoMatch( + new Request('https://example.com/api/v1X0/users'), + 200, + new Headers(), + routerContext, + {} as any, + ); + + expect(updateSpanName).not.toHaveBeenCalled(); + }); + + test('should match manifest routes when URL has trailing slash', async () => { + const { wrappedHandler, mockRootSpan, mockSetTransactionName } = setupManifestTest(); + + const routerContext = { + staticHandlerContext: { + matches: [], + }, + manifest: { + routes: { + root: { path: undefined, parentId: undefined }, + 'rsc-layout': { path: 'rsc', parentId: 'root' }, + 'rsc-server-component': { path: 'server-component', parentId: 'rsc-layout' }, + }, + }, + } as any; + + await wrappedHandler( + new Request('https://example.com/rsc/server-component/'), + 200, + new Headers(), + routerContext, + {} as any, + ); + + expect(updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /rsc/server-component'); + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /rsc/server-component'); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [ATTR_HTTP_ROUTE]: '/rsc/server-component', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }), + ); + }); + + test('should prefer staticHandlerContext.matches over manifest', async () => { + const { wrappedHandler, mockRootSpan, mockSetTransactionName } = setupManifestTest(); + + const routerContext = { + staticHandlerContext: { + matches: [{ route: { path: 'static-path' } }], + }, + manifest: { + routes: { + root: { path: undefined, parentId: undefined }, + 'manifest-route': { path: 'manifest-path', parentId: 'root' }, + }, + }, + } as any; + + await wrappedHandler(new Request('https://example.com/static-path'), 200, new Headers(), routerContext, {} as any); + + expect(updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /static-path'); + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /static-path'); + }); + + test('should not set attributes when manifest is missing', async () => { + const { wrappedHandler, mockRootSpan } = setupManifestTest(); + + const routerContext = { + staticHandlerContext: { + matches: [], + }, + } as any; + + await wrappedHandler(new Request('https://example.com/some-path'), 200, new Headers(), routerContext, {} as any); + + expect(mockRootSpan.setAttributes).not.toHaveBeenCalled(); + expect(updateSpanName).not.toHaveBeenCalled(); + }); + 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; @@ -213,6 +411,108 @@ describe('wrapSentryHandleRequest', () => { }); expect(mockRpcMetadata.route).toBe('/some-path'); }); + + test('should prefer static route over parameterized route with similar path length', async () => { + const { wrappedHandler, mockRootSpan, mockSetTransactionName } = setupManifestTest(); + + const routerContext = { + staticHandlerContext: { + matches: [], + }, + manifest: { + routes: { + root: { path: undefined, parentId: undefined }, + 'api-name': { path: 'api/:name', parentId: 'root' }, + 'api-users': { path: 'api/users', parentId: 'root' }, + }, + }, + } as any; + + await wrappedHandler(new Request('https://example.com/api/users'), 200, new Headers(), routerContext, {} as any); + + expect(updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /api/users'); + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /api/users'); + }); + + test('should match static route even when shorter than parameterized route', async () => { + const { wrappedHandler, mockRootSpan, mockSetTransactionName } = setupManifestTest(); + + const routerContext = { + staticHandlerContext: { + matches: [], + }, + manifest: { + routes: { + root: { path: undefined, parentId: undefined }, + users: { path: 'users', parentId: 'root' }, + 'users-id': { path: ':id', parentId: 'users' }, + 'users-me': { path: 'me', parentId: 'users' }, + }, + }, + } as any; + + await wrappedHandler(new Request('https://example.com/users/me'), 200, new Headers(), routerContext, {} as any); + + expect(updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /users/me'); + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /users/me'); + }); + + test('should match wildcard catch-all route as least-specific fallback', async () => { + const { wrappedHandler, mockRootSpan, mockSetTransactionName } = setupManifestTest(); + + const routerContext = { + staticHandlerContext: { + matches: [], + }, + manifest: { + routes: { + root: { path: undefined, parentId: undefined }, + about: { path: 'about', parentId: 'root' }, + catchall: { path: '*', parentId: 'root' }, + }, + }, + } as any; + + // /unknown should fall through to catch-all + await wrappedHandler( + new Request('https://example.com/unknown/deep/path'), + 200, + new Headers(), + routerContext, + {} as any, + ); + + expect(updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /*'); + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /*'); + }); + + test('should match wildcard route with prefix', async () => { + const { wrappedHandler, mockRootSpan, mockSetTransactionName } = setupManifestTest(); + + const routerContext = { + staticHandlerContext: { + matches: [], + }, + manifest: { + routes: { + root: { path: undefined, parentId: undefined }, + docs: { path: 'docs', parentId: 'root' }, + 'docs-catchall': { path: '*', parentId: 'docs' }, + }, + }, + } as any; + + await wrappedHandler( + new Request('https://example.com/docs/api/reference'), + 200, + new Headers(), + routerContext, + {} as any, + ); + + expect(updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /docs/*'); + expect(mockSetTransactionName).toHaveBeenCalledWith('GET /docs/*'); + }); }); describe('getMetaTagTransformer', () => { diff --git a/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts b/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts new file mode 100644 index 000000000000..1fc63de25e25 --- /dev/null +++ b/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts @@ -0,0 +1,515 @@ +import type { Plugin } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + analyzeModule, + getServerFunctionWrapperCode, + makeAutoInstrumentRSCPlugin, +} from '../../src/vite/makeAutoInstrumentRSCPlugin'; + +vi.spyOn(console, 'log').mockImplementation(() => { + /* noop */ +}); +vi.spyOn(console, 'warn').mockImplementation(() => { + /* noop */ +}); + +type PluginWithHooks = Plugin & { + configResolved: (config: { plugins: Array<{ name: string }> }) => void; + resolveId: (source: string) => string | null; + load: (id: string) => Promise; + transform: (code: string, id: string) => { code: string; map: null } | null; +}; + +const RSC_PLUGINS_CONFIG = { plugins: [{ name: 'react-router/rsc' }] }; +const NON_RSC_PLUGINS_CONFIG = { plugins: [{ name: 'react-router' }] }; + +/** Creates a plugin with RSC mode detected (simulates `configResolved` with RSC plugins). */ +function createPluginWithRSCDetected(options: Parameters[0] = {}): PluginWithHooks { + const plugin = makeAutoInstrumentRSCPlugin(options) as PluginWithHooks; + plugin.configResolved(RSC_PLUGINS_CONFIG); + return plugin; +} + +describe('makeAutoInstrumentRSCPlugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetModules(); + }); + + describe('resolveId', () => { + it('resolves modules with the wrapped suffix', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + expect(plugin.resolveId('/app/routes/page.tsx?sentry-rsc-wrap')).toBe('/app/routes/page.tsx?sentry-rsc-wrap'); + }); + + it('returns null for normal modules', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + expect(plugin.resolveId('/app/routes/page.tsx')).toBeNull(); + }); + }); + + describe('load', () => { + it('returns null for non-wrapped modules', async () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + await expect(plugin.load('/app/routes/page.tsx')).resolves.toBeNull(); + }); + + it('reads the original file for wrapped modules', async () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + const result = await plugin.load(`${__filename}?sentry-rsc-wrap`); + expect(result).not.toBeNull(); + expect(typeof result).toBe('string'); + expect(result).toContain('makeAutoInstrumentRSCPlugin'); + }); + + it('returns null and logs when the original file does not exist', async () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true, debug: true }) as PluginWithHooks; + const result = await plugin.load('/nonexistent/file.tsx?sentry-rsc-wrap'); + expect(result).toBeNull(); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Failed to read original file:')); + }); + }); + + describe('configResolved', () => { + it('detects RSC mode when react-router/rsc plugin is present', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + plugin.configResolved(RSC_PLUGINS_CONFIG); + + const code = "'use server';\nexport async function myAction() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + expect(result).not.toBeNull(); + }); + + it('does not detect RSC mode when only standard react-router plugin is present', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + plugin.configResolved(NON_RSC_PLUGINS_CONFIG); + + const code = "'use server';\nexport async function myAction() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + expect(result).toBeNull(); + }); + + it('does not wrap when configResolved has not been called', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true }) as PluginWithHooks; + + const code = "'use server';\nexport async function myAction() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + expect(result).toBeNull(); + }); + + it('logs detection status when debug is enabled', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true, debug: true }) as PluginWithHooks; + plugin.configResolved(RSC_PLUGINS_CONFIG); + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('[Sentry RSC] RSC mode detected'); + }); + + it('logs non-detection status when debug is enabled', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: true, debug: true }) as PluginWithHooks; + plugin.configResolved(NON_RSC_PLUGINS_CONFIG); + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('[Sentry RSC] RSC mode not detected'); + }); + }); + + describe('transform', () => { + it('returns null when disabled', () => { + const plugin = makeAutoInstrumentRSCPlugin({ enabled: false }) as PluginWithHooks; + const code = "'use server';\nexport async function myAction() {}"; + expect(plugin.transform(code, 'app/routes/rsc/actions.ts')).toBeNull(); + }); + + it('returns null for non-TS/JS files', () => { + const plugin = createPluginWithRSCDetected(); + expect(plugin.transform('some content', 'app/routes/styles.css')).toBeNull(); + }); + + it('returns null for wrapped module suffix (prevents infinite loop)', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport async function myAction() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts?sentry-rsc-wrap'); + expect(result).toBeNull(); + }); + + it('wraps "use server" files with server function wrapper code', () => { + const plugin = createPluginWithRSCDetected(); + const code = [ + "'use server';", + 'export async function submitForm(data) { return data; }', + 'export async function getData() { return {}; }', + ].join('\n'); + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain("'use server'"); + expect(result!.code).toContain("import { wrapServerFunction } from '@sentry/react-router'"); + expect(result!.code).toContain('import * as _sentry_original from'); + expect(result!.code).toContain('app/routes/rsc/actions.ts?sentry-rsc-wrap'); + expect(result!.code).toContain('export const submitForm = wrapServerFunction("submitForm"'); + expect(result!.code).toContain('export const getData = wrapServerFunction("getData"'); + }); + + it('wraps "use server" files preceded by comments', () => { + const plugin = createPluginWithRSCDetected(); + const code = ['// Copyright 2024', '/* License */', "'use server';", 'export async function myAction() {}'].join( + '\n', + ); + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('export const myAction = wrapServerFunction("myAction"'); + }); + + it('returns null for "use server" files with no named exports', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nfunction internalHelper() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).toBeNull(); + }); + + it('skips "use server" files already containing wrapServerFunction', () => { + const plugin = createPluginWithRSCDetected(); + const code = [ + "'use server';", + "import { wrapServerFunction } from '@sentry/react-router';", + "export const action = wrapServerFunction('action', _action);", + ].join('\n'); + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).toBeNull(); + }); + + it('wraps "use server" files with export const pattern', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport const myAction = async () => {};"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('export const myAction = wrapServerFunction("myAction"'); + }); + + it('logs debug messages when wrapping server functions', () => { + const plugin = createPluginWithRSCDetected({ debug: true }); + const code = "'use server';\nexport async function myAction() {}"; + plugin.transform(code, 'app/routes/rsc/actions.ts'); + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[Sentry RSC] Auto-wrapping server functions:')); + }); + + it('logs debug messages when skipping "use server" file with no exports', () => { + const plugin = createPluginWithRSCDetected({ debug: true }); + const code = "'use server';\nfunction internal() {}"; + plugin.transform(code, 'app/routes/rsc/actions.ts'); + + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('[Sentry RSC] Skipping server function file with no exports:'), + ); + }); + + it('wraps "use server" files with both named and default exports', () => { + const plugin = createPluginWithRSCDetected(); + const code = [ + "'use server';", + 'export async function namedAction() { return {}; }', + 'export default async function defaultAction() { return {}; }', + ].join('\n'); + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('export const namedAction = wrapServerFunction("namedAction"'); + expect(result!.code).toContain('export default wrapServerFunction("default", _sentry_original.default)'); + }); + + it('wraps "use server" files with only default export', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport default async function serverAction() { return {}; }"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain("'use server'"); + expect(result!.code).toContain('export default wrapServerFunction("default", _sentry_original.default)'); + }); + + it('wraps export class in a "use server" file', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport class MyService { async run() {} }"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('export const MyService = wrapServerFunction("MyService"'); + }); + + it('does not extract "export default async function name" as a named export', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport default async function serverAction() { return {}; }"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + // Should wrap as default, not as a named export called "serverAction" + expect(result!.code).toContain('export default wrapServerFunction("default", _sentry_original.default)'); + expect(result!.code).not.toContain('export const serverAction'); + }); + + it('wraps "use server" files regardless of their directory location', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport async function myAction() {}"; + + // Should work from any directory, not just routes + const result = plugin.transform(code, 'app/lib/server-actions.ts'); + expect(result).not.toBeNull(); + expect(result!.code).toContain('export const myAction = wrapServerFunction("myAction"'); + }); + + it.each(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts'])('wraps "use server" files with %s extension', ext => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport async function action() {}"; + const result = plugin.transform(code, `app/routes/rsc/actions${ext}`); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('wrapServerFunction'); + }); + + it('skips string literal export names that are not valid identifiers', () => { + const plugin = createPluginWithRSCDetected(); + const code = '\'use server\';\nfunction a() {}\nfunction b() {}\nexport { a as "my-action", b }'; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain('export const b = wrapServerFunction("b"'); + expect(result!.code).not.toContain('my-action'); + }); + + it('does not log when debug is disabled', () => { + const plugin = createPluginWithRSCDetected({ debug: false }); + + plugin.transform("'use server';\nexport async function action() {}", 'app/routes/rsc/actions.ts'); + + // eslint-disable-next-line no-console + expect(console.log).not.toHaveBeenCalled(); + // eslint-disable-next-line no-console + expect(console.warn).not.toHaveBeenCalled(); + }); + }); + + describe('analyzeModule', () => { + it.each<[string, string, string[]]>([ + ['export const', "export const submitForm = wrapServerFunction('submitForm', _submitForm);", ['submitForm']], + ['export function', 'export function submitForm(data) { return data; }', ['submitForm']], + ['export async function', 'export async function fetchData() { return await fetch("/api"); }', ['fetchData']], + ])('extracts %s declarations', (_label, code, expected) => { + expect(analyzeModule(code)?.namedExports).toEqual(expected); + }); + + it('extracts multiple exports', () => { + const code = [ + 'export async function submitForm(data) {}', + 'export async function getData() {}', + 'export const CONFIG = {};', + ].join('\n'); + expect(analyzeModule(code)?.namedExports).toEqual(expect.arrayContaining(['submitForm', 'getData', 'CONFIG'])); + expect(analyzeModule(code)?.namedExports).toHaveLength(3); + }); + + it('extracts export { a, b, c } specifiers', () => { + const code = 'function a() {}\nfunction b() {}\nexport { a, b }'; + expect(analyzeModule(code)?.namedExports).toEqual(['a', 'b']); + }); + + it('extracts aliased exports using the exported name', () => { + const code = 'function _internal() {}\nexport { _internal as publicName }'; + expect(analyzeModule(code)?.namedExports).toEqual(['publicName']); + }); + + it('returns empty array when no exports are found', () => { + const code = 'function helper() { return 42; }'; + expect(analyzeModule(code)?.namedExports).toEqual([]); + }); + + it('ignores export default', () => { + const code = 'export default function Page() {}'; + expect(analyzeModule(code)?.namedExports).toEqual([]); + }); + + it('treats export { x as default } as a default export, not a named export', () => { + const result = analyzeModule('function myFunc() {}\nexport { myFunc as default }'); + expect(result?.namedExports).toEqual([]); + expect(result?.hasDefaultExport).toBe(true); + }); + + it('deduplicates exports', () => { + const code = 'export const a = 1;\nexport { a }'; + expect(analyzeModule(code)?.namedExports).toEqual(['a']); + }); + + it('handles mixed export styles', () => { + const code = [ + 'export const a = 1;', + 'export function b() {}', + 'export async function c() {}', + 'function d() {}', + 'export { d }', + ].join('\n'); + expect(analyzeModule(code)?.namedExports).toEqual(['a', 'b', 'c', 'd']); + }); + + it('ignores type-only exports', () => { + const code = [ + 'export type MyType = string;', + 'export interface MyInterface {}', + 'export const realExport = 1;', + ].join('\n'); + expect(analyzeModule(code)?.namedExports).toEqual(['realExport']); + }); + + it.each([ + ['export { type Foo, bar, type Baz as Qux }', ['bar']], + ['export { type MyType, a, b }', ['a', 'b']], + ])('ignores inline type specifiers in "%s"', (exportLine, expected) => { + const code = `type Foo = string;\ntype MyType = string;\ntype Baz = number;\nconst bar = 1;\nconst a = 1;\nconst b = 2;\n${exportLine}`; + expect(analyzeModule(code)?.namedExports).toEqual(expected); + }); + + it('extracts export class declarations', () => { + expect(analyzeModule('export class MyClass {}')?.namedExports).toEqual(['MyClass']); + }); + + it('does not extract "export default async function name" as a named export', () => { + const result = analyzeModule('export default async function serverAction() { return {}; }'); + expect(result?.namedExports).toEqual([]); + expect(result?.hasDefaultExport).toBe(true); + }); + + it('detects "use server" directive', () => { + const result = analyzeModule("'use server';\nexport async function action() {}"); + expect(result?.hasUseServerDirective).toBe(true); + }); + + it('detects "use server" combined with "use strict"', () => { + const result = analyzeModule("'use strict';\n'use server';\nexport async function action() {}"); + expect(result?.hasUseServerDirective).toBe(true); + }); + + it('does not treat "use server" inside a comment as a directive', () => { + const result = analyzeModule('// "use server"\nexport async function action() {}'); + expect(result?.hasUseServerDirective).toBe(false); + }); + + it('does not treat "use server" inside a string as a directive', () => { + const result = analyzeModule('const x = "use server";\nexport async function action() {}'); + expect(result?.hasUseServerDirective).toBe(false); + }); + + it('detects default export', () => { + expect(analyzeModule('export default function Page() {}')?.hasDefaultExport).toBe(true); + }); + + it('reports no default export when none exists', () => { + expect(analyzeModule('export function helper() {}')?.hasDefaultExport).toBe(false); + }); + + it('detects manual wrapping with wrapServerFunction import', () => { + const code = + "import { wrapServerFunction } from '@sentry/react-router';\nexport const action = wrapServerFunction('action', _action);"; + expect(analyzeModule(code)?.hasManualServerFunctionWrapping).toBe(true); + }); + + it('does not treat wrapServerFunction in a comment as manual wrapping', () => { + const result = analyzeModule( + "// import { wrapServerFunction } from '@sentry/react-router';\nexport async function action() {}", + ); + expect(result?.hasManualServerFunctionWrapping).toBe(false); + }); + + it('returns null for unparseable code', () => { + expect(analyzeModule('this is not valid {{{')).toBeNull(); + }); + }); + + describe('getServerFunctionWrapperCode', () => { + it('generates wrapper code with use server directive', () => { + const result = getServerFunctionWrapperCode('/app/routes/rsc/actions.ts', ['submitForm', 'getData']); + + expect(result).toContain("'use server'"); + expect(result).toContain("import { wrapServerFunction } from '@sentry/react-router'"); + expect(result).toContain('import * as _sentry_original from'); + expect(result).toContain('/app/routes/rsc/actions.ts?sentry-rsc-wrap'); + }); + + it('wraps each named export with wrapServerFunction', () => { + const result = getServerFunctionWrapperCode('/app/routes/rsc/actions.ts', ['submitForm', 'getData']); + + expect(result).toContain( + 'export const submitForm = wrapServerFunction("submitForm", _sentry_original["submitForm"])', + ); + expect(result).toContain('export const getData = wrapServerFunction("getData", _sentry_original["getData"])'); + }); + + it('handles a single export', () => { + const result = getServerFunctionWrapperCode('/app/routes/rsc/actions.ts', ['myAction']); + + expect(result).toContain('export const myAction = wrapServerFunction("myAction", _sentry_original["myAction"])'); + }); + + it('escapes special characters in export names via JSON.stringify', () => { + const result = getServerFunctionWrapperCode('/app/routes/actions.ts', ['$action']); + + expect(result).toContain('export const $action = wrapServerFunction("$action", _sentry_original["$action"])'); + }); + + it('includes wrapped default export when includeDefault is true', () => { + const result = getServerFunctionWrapperCode('/app/routes/actions.ts', ['namedAction'], true); + + expect(result).toContain( + 'export const namedAction = wrapServerFunction("namedAction", _sentry_original["namedAction"])', + ); + expect(result).toContain('export default wrapServerFunction("default", _sentry_original.default)'); + }); + + it('does not include default export when includeDefault is false', () => { + const result = getServerFunctionWrapperCode('/app/routes/actions.ts', ['namedAction'], false); + + expect(result).toContain('export const namedAction = wrapServerFunction("namedAction"'); + expect(result).not.toContain('export default'); + }); + + it('strips query strings from the original ID before appending the suffix', () => { + const result = getServerFunctionWrapperCode('/app/routes/actions.ts?v=123', ['myAction']); + + expect(result).toContain('/app/routes/actions.ts?sentry-rsc-wrap'); + expect(result).not.toContain('?v=123'); + }); + + it('handles file with only default export when includeDefault is true', () => { + const result = getServerFunctionWrapperCode('/app/routes/actions.ts', [], true); + + expect(result).toContain("'use server'"); + expect(result).toContain('export default wrapServerFunction("default", _sentry_original.default)'); + }); + }); + + describe('plugin creation', () => { + it('creates a plugin with the correct name and enforce value', () => { + const plugin = makeAutoInstrumentRSCPlugin(); + + expect(plugin.name).toBe('sentry-react-router-rsc-auto-instrument'); + expect(plugin.enforce).toBe('pre'); + }); + + it('defaults to enabled when no options are provided', () => { + const plugin = createPluginWithRSCDetected(); + const code = "'use server';\nexport async function action() {}"; + const result = plugin.transform(code, 'app/routes/rsc/actions.ts'); + + expect(result).not.toBeNull(); + }); + }); +}); diff --git a/packages/react-router/test/vite/plugin.test.ts b/packages/react-router/test/vite/plugin.test.ts index 52306eb0dbd1..64b07863cc3d 100644 --- a/packages/react-router/test/vite/plugin.test.ts +++ b/packages/react-router/test/vite/plugin.test.ts @@ -8,9 +8,6 @@ import { sentryReactRouter } from '../../src/vite/plugin'; vi.spyOn(console, 'log').mockImplementation(() => { /* noop */ }); -vi.spyOn(console, 'warn').mockImplementation(() => { - /* noop */ -}); vi.mock('../../src/vite/makeCustomSentryVitePlugins'); vi.mock('../../src/vite/makeEnableSourceMapsPlugin'); @@ -41,7 +38,9 @@ describe('sentryReactRouter', () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin]); + expect(result).toHaveLength(2); + expect(result).toContainEqual(mockConfigInjectorPlugin); + expect(result).toContainEqual(mockServerBuildCapturePlugin); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); @@ -51,7 +50,9 @@ describe('sentryReactRouter', () => { it('should return config injector plugin when not in build mode', async () => { const result = await sentryReactRouter({}, { command: 'serve', mode: 'production' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin]); + expect(result).toHaveLength(2); + expect(result).toContainEqual(mockConfigInjectorPlugin); + expect(result).toContainEqual(mockServerBuildCapturePlugin); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -59,7 +60,9 @@ describe('sentryReactRouter', () => { it('should return config injector plugin in development build mode', async () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'development' }); - expect(result).toEqual([mockConfigInjectorPlugin, mockServerBuildCapturePlugin]); + expect(result).toHaveLength(2); + expect(result).toContainEqual(mockConfigInjectorPlugin); + expect(result).toContainEqual(mockServerBuildCapturePlugin); expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled(); expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled(); }); @@ -70,12 +73,11 @@ describe('sentryReactRouter', () => { const result = await sentryReactRouter({}, { command: 'build', mode: 'production' }); - expect(result).toEqual([ - mockConfigInjectorPlugin, - mockServerBuildCapturePlugin, - mockSourceMapsPlugin, - ...mockPlugins, - ]); + expect(result).toHaveLength(4); + expect(result).toContainEqual(mockConfigInjectorPlugin); + expect(result).toContainEqual(mockServerBuildCapturePlugin); + expect(result).toContainEqual(mockSourceMapsPlugin); + expect(result).toContainEqual(mockPlugins[0]); expect(makeConfigInjectorPlugin).toHaveBeenCalledWith({}); expect(makeServerBuildCapturePlugin).toHaveBeenCalled(); expect(makeCustomSentryVitePlugins).toHaveBeenCalledWith({}); @@ -84,6 +86,32 @@ describe('sentryReactRouter', () => { process.env.NODE_ENV = originalNodeEnv; }); + it('should not include RSC auto-instrument plugin by default', async () => { + const result = await sentryReactRouter({}, { command: 'serve', mode: 'development' }); + + expect(result).toHaveLength(2); + expect(result).not.toContainEqual(expect.objectContaining({ name: 'sentry-react-router-rsc-auto-instrument' })); + }); + + it('should include RSC auto-instrument plugin when enabled is explicitly true', async () => { + const result = await sentryReactRouter( + { experimental_rscAutoInstrumentation: { enabled: true } }, + { command: 'serve', mode: 'development' }, + ); + + expect(result).toContainEqual(expect.objectContaining({ name: 'sentry-react-router-rsc-auto-instrument' })); + }); + + it('should not include RSC auto-instrument plugin when enabled is explicitly false', async () => { + const result = await sentryReactRouter( + { experimental_rscAutoInstrumentation: { enabled: false } }, + { command: 'serve', mode: 'development' }, + ); + + expect(result).toHaveLength(2); + expect(result).not.toContainEqual(expect.objectContaining({ name: 'sentry-react-router-rsc-auto-instrument' })); + }); + it('should pass release configuration to plugins', async () => { const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production';