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..012e938ef384
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/.gitignore
@@ -0,0 +1,10 @@
+node_modules
+
+/.cache
+/build
+.env
+
+/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/README.md b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/README.md
new file mode 100644
index 000000000000..9163c5ff69c4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/README.md
@@ -0,0 +1,5 @@
+# React Router 7 RSC
+
+E2E test app for React Router 7 RSC (React Server Components) and `@sentry/react-router`.
+
+**Note:** Skipped in CI (`sentryTest.skip: true`) - React Router's RSC Framework Mode is experimental.
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..36331bc72654
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/app.css
@@ -0,0 +1,47 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ margin: 0;
+ padding: 20px;
+ line-height: 1.6;
+}
+
+h1 {
+ margin-top: 0;
+}
+
+nav {
+ margin-bottom: 20px;
+}
+
+nav ul {
+ list-style: none;
+ padding: 0;
+ display: flex;
+ gap: 20px;
+}
+
+nav a {
+ color: #0066cc;
+ text-decoration: none;
+}
+
+nav a:hover {
+ text-decoration: underline;
+}
+
+button {
+ padding: 8px 16px;
+ font-size: 14px;
+ cursor: pointer;
+}
+
+.error {
+ color: #cc0000;
+ background: #ffeeee;
+ padding: 10px;
+ border-radius: 4px;
+}
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..468cb79fc6f5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/root.tsx
@@ -0,0 +1,59 @@
+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}
+
+ {/* is not needed in RSC mode - scripts are injected by the RSC framework */}
+
+
+ );
+}
+
+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..51948e4d322f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/dynamic-param.tsx
@@ -0,0 +1,10 @@
+import type { Route } from './+types/dynamic-param';
+
+export default function DynamicParamPage({ params }: Route.ComponentProps) {
+ return (
+
+ Dynamic Param Page
+ Param: {params.param}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-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..459806f56e17
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/performance/index.tsx
@@ -0,0 +1,16 @@
+import { Link } from 'react-router';
+
+export default function PerformancePage() {
+ return (
+
+ Performance Test
+
+
+ );
+}
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..0f39749fc4a3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/actions.ts
@@ -0,0 +1,27 @@
+'use server';
+
+export async function submitForm(formData: FormData): Promise<{ success: boolean; message: string }> {
+ const name = formData.get('name') as string;
+
+ // Simulate some async work
+ 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 }> {
+ // Simulate an error in server function
+ 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..a19bcb13e0d3
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component-async.tsx
@@ -0,0 +1,31 @@
+import { wrapServerComponent } from '@sentry/react-router';
+import type { Route } from './+types/server-component-async';
+
+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(_props: Route.ComponentProps) {
+ const data = await fetchData();
+
+ return (
+
+ {data.title}
+ {data.content}
+
+ );
+}
+
+export default wrapServerComponent(AsyncServerComponent, {
+ componentRoute: '/rsc/server-component-async',
+ componentType: 'Page',
+});
+
+export async function loader() {
+ const data = await fetchData();
+ return data;
+}
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..215decf3639e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-component.tsx
@@ -0,0 +1,23 @@
+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
+ This demonstrates a manually wrapped 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..f7ff1edf14c6
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-arrow.tsx
@@ -0,0 +1,33 @@
+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
+ This tests export const arrow function wrapping.
+
+
+
+ {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..8dbfd2990935
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-default.tsx
@@ -0,0 +1,33 @@
+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
+ This tests "use server" files with only a default export.
+
+
+
+ {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..3d72bab7ccf0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function-error.tsx
@@ -0,0 +1,32 @@
+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
+ This page tests error capture in wrapServerFunction.
+
+
+
+ {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..af147366f4c1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/routes/rsc/server-function.tsx
@@ -0,0 +1,34 @@
+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
+ This page tests wrapServerFunction instrumentation.
+
+
+
+ {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..2349c00ce937
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/app/sentry-client.tsx
@@ -0,0 +1,31 @@
+'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(e => {
+ // Silent fail in production, but log in dev for debugging
+ if (import.meta.env.DEV) {
+ // eslint-disable-next-line no-console
+ console.warn('[Sentry] Failed to initialize:', e);
+ }
+ });
+ }, []);
+
+ 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..d9d1ea7f386e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/instrument.mjs
@@ -0,0 +1,9 @@
+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
+ debug: true,
+});
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..5a8f65710f15
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/package.json
@@ -0,0 +1,64 @@
+{
+ "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-react": "4.5.1",
+ "@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 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..3de973d1a5ef
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/performance/performance.server.test.ts
@@ -0,0 +1,115 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+import { APP_NAME } from '../constants';
+
+test.describe('RSC - Performance', () => {
+ test('should send server transaction on pageload', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, transactionEvent => {
+ const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node';
+ const matchesRoute =
+ transactionEvent.transaction?.includes('/performance') ||
+ transactionEvent.request?.url?.includes('/performance');
+ return Boolean(isServerTransaction && matchesRoute && !transactionEvent.request?.url?.includes('/with/'));
+ });
+
+ await page.goto(`/performance`);
+
+ const transaction = await txPromise;
+
+ expect(transaction).toMatchObject({
+ type: 'transaction',
+ transaction: expect.stringMatching(/GET \/performance|GET \*/),
+ platform: 'node',
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.op': 'http.server',
+ 'sentry.origin': expect.stringMatching(/auto\.http\.(otel\.http|react_router\.request_handler)/),
+ 'sentry.source': expect.stringMatching(/route|url/),
+ },
+ op: 'http.server',
+ origin: expect.stringMatching(/auto\.http\.(otel\.http|react_router\.request_handler)/),
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ transaction_info: { source: expect.stringMatching(/route|url/) },
+ 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: expect.arrayContaining([
+ expect.objectContaining({ name: 'npm:@sentry/react-router', version: expect.any(String) }),
+ expect.objectContaining({ 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 => {
+ const isServerTransaction = transactionEvent.contexts?.runtime?.name === 'node';
+ const matchesRoute =
+ transactionEvent.transaction?.includes('/performance/with') ||
+ transactionEvent.request?.url?.includes('/performance/with/some-param');
+ return Boolean(isServerTransaction && matchesRoute);
+ });
+
+ await page.goto(`/performance/with/some-param`);
+
+ const transaction = await txPromise;
+
+ expect(transaction).toMatchObject({
+ type: 'transaction',
+ transaction: expect.stringMatching(/GET \/performance\/with|GET \*/),
+ platform: 'node',
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.op': 'http.server',
+ 'sentry.origin': expect.stringMatching(/auto\.http\.(otel\.http|react_router\.request_handler)/),
+ 'sentry.source': expect.stringMatching(/route|url/),
+ },
+ op: 'http.server',
+ origin: expect.stringMatching(/auto\.http\.(otel\.http|react_router\.request_handler)/),
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ transaction_info: { source: expect.stringMatching(/route|url/) },
+ 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: expect.arrayContaining([
+ expect.objectContaining({ name: 'npm:@sentry/react-router', version: expect.any(String) }),
+ expect.objectContaining({ 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..7fd3e3cbd4c4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tests/rsc/server-component.test.ts
@@ -0,0 +1,224 @@
+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 && !transactionEvent.transaction?.includes('-async'));
+ });
+
+ 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.stringMatching(/\/rsc\/server-component|GET \*/),
+ platform: 'node',
+ environment: 'qa',
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ sdk: {
+ name: 'sentry.javascript.react-router',
+ version: expect.any(String),
+ packages: expect.arrayContaining([
+ expect.objectContaining({ name: 'npm:@sentry/react-router', version: expect.any(String) }),
+ expect.objectContaining({ name: 'npm:@sentry/node', version: 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.stringMatching(/\/rsc\/server-component-async|GET \*/),
+ platform: 'node',
+ environment: 'qa',
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ sdk: {
+ name: 'sentry.javascript.react-router',
+ version: expect.any(String),
+ packages: expect.arrayContaining([
+ expect.objectContaining({ name: 'npm:@sentry/react-router', version: expect.any(String) }),
+ expect.objectContaining({ name: 'npm:@sentry/node', version: 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.stringMatching(/\/rsc\/server-component-comment-directive|GET \*/),
+ 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 && !transactionEvent.transaction?.includes('-async'));
+ });
+
+ 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.stringMatching(/\/rsc\/server-component|GET \*/),
+ platform: 'node',
+ environment: 'qa',
+ contexts: {
+ trace: {
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ },
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ sdk: {
+ name: 'sentry.javascript.react-router',
+ version: expect.any(String),
+ packages: expect.arrayContaining([
+ expect.objectContaining({ name: 'npm:@sentry/react-router', version: expect.any(String) }),
+ expect.objectContaining({ name: 'npm:@sentry/node', version: 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..f72c33c881ef
--- /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: expect.arrayContaining([
+ expect.objectContaining({ name: 'npm:@sentry/react-router', version: expect.any(String) }),
+ expect.objectContaining({ 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..6b11840e7262
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "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": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+ "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..6ef5f7b97378
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-rsc/vite.config.ts
@@ -0,0 +1,21 @@
+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({}, env)), unstable_reactRouterRSC(), rsc()],
+ // Exclude chokidar from RSC bundling - it's a CommonJS file watcher
+ // that causes parse errors when the RSC plugin tries to process it
+ 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 8a6e449e0e37..f8157c14b4e0 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.0",
"@opentelemetry/instrumentation": "^0.211.0",
@@ -55,7 +56,8 @@
"@sentry/node": "10.38.0",
"@sentry/react": "10.38.0",
"@sentry/vite-plugin": "^4.8.0",
- "glob": "^13.0.1"
+ "glob": "^13.0.1",
+ "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..226be4454eee 100644
--- a/packages/react-router/src/client/index.ts
+++ b/packages/react-router/src/client/index.ts
@@ -12,6 +12,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: { componentRoute: string; componentType: string },
+): 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?: { name?: string; attributes?: Record },
+): 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..d74abee0ba6b
--- /dev/null
+++ b/packages/react-router/src/server/rsc/responseUtils.ts
@@ -0,0 +1,64 @@
+/**
+ * Read-only check for the `__sentry_captured__` flag set by `captureException`.
+ * Only reads the flag — does not mark the error — to avoid conflicting with
+ * the internal dedup in `captureException`.
+ */
+export function isAlreadyCaptured(exception: unknown): boolean {
+ if (exception == null || typeof exception !== 'object') {
+ return false;
+ }
+ try {
+ return !!(exception as Record).__sentry_captured__;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * 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 (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..39141cf9c87d
--- /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' | 'Loading' | 'Error' | 'Template' | 'Not-found' | '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..06bd1d07eb11
--- /dev/null
+++ b/packages/react-router/src/server/rsc/wrapServerComponent.ts
@@ -0,0 +1,117 @@
+import {
+ captureException,
+ debug,
+ flushIfServerless,
+ getActiveSpan,
+ getIsolationScope,
+ SPAN_STATUS_ERROR,
+ SPAN_STATUS_OK,
+} from '@sentry/core';
+import { DEBUG_BUILD } from '../../common/debug-build';
+import { isAlreadyCaptured, isNotFoundResponse, isRedirectResponse } from './responseUtils';
+import type { ServerComponentContext } from './types';
+
+/**
+ * Wraps a server component with Sentry error instrumentation.
+ *
+ * @experimental This API is experimental and may change in minor releases.
+ * React Router RSC support requires React Router v7.9.0+ with `unstable_reactRouterRSC()`.
+ *
+ * @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) => {
+ const isolationScope = getIsolationScope();
+
+ const transactionName = `${componentType} Server Component (${componentRoute})`;
+ isolationScope.setTransactionName(transactionName);
+
+ 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') {
+ // Attach handlers as side-effects. These create new promises but we intentionally
+ // return the original 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' });
+ }
+
+ if (!isAlreadyCaptured(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..0a0aab7ccdeb
--- /dev/null
+++ b/packages/react-router/src/server/rsc/wrapServerFunction.ts
@@ -0,0 +1,105 @@
+import {
+ captureException,
+ debug,
+ flushIfServerless,
+ getActiveSpan,
+ getIsolationScope,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ SPAN_STATUS_ERROR,
+ SPAN_STATUS_OK,
+ startSpan,
+} from '@sentry/core';
+import { DEBUG_BUILD } from '../../common/debug-build';
+import { isAlreadyCaptured, 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 This API is experimental and may change in minor releases.
+ * React Router RSC support requires React Router v7.9.0+ with `unstable_reactRouterRSC()`.
+ *
+ * @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 {
+ 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();
+
+ return startSpan(
+ {
+ 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);
+ return result;
+ } catch (error) {
+ if (isRedirectResponse(error)) {
+ span.setStatus({ code: SPAN_STATUS_OK });
+ throw error;
+ }
+
+ if (isNotFoundResponse(error)) {
+ span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
+ throw error;
+ }
+
+ span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
+
+ if (!isAlreadyCaptured(error)) {
+ captureException(error, {
+ mechanism: {
+ type: 'react_router.rsc',
+ handled: false,
+ data: {
+ function: 'serverFunction',
+ server_function_name: functionName,
+ },
+ },
+ });
+ }
+ throw error;
+ } finally {
+ await flushIfServerless();
+ }
+ },
+ );
+ },
+ });
+}
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..76e0669a87e8
--- /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;
+
+const JS_EXTENSIONS_RE = /\.(ts|tsx|js|jsx|mjs|mts)$/;
+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 {
+ hasUseClientDirective: boolean;
+ 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 client"/"use server") from the program. */
+function extractDirectives(program: t.Program): { useClient: boolean; useServer: boolean } {
+ let useClient = false;
+ 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 client') {
+ useClient = true;
+ }
+ 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 client') {
+ useClient = true;
+ }
+ if (value === 'use server') {
+ useServer = true;
+ }
+ }
+
+ return { useClient, 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 {
+ hasUseClientDirective: directives.useClient,
+ hasUseServerDirective: directives.useServer,
+ hasDefaultExport,
+ hasManualServerFunctionWrapping,
+ namedExports: [...namedExportSet],
+ };
+}
+
+/** Exported for testing. */
+export function getServerFunctionWrapperCode(
+ originalId: string,
+ exportNames: string[],
+ includeDefault: boolean = false,
+): string {
+ const wrappedId = JSON.stringify(`${originalId}${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: AutoInstrumentRSCOptions = {}): 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 originalPath = id.slice(0, -WRAPPED_MODULE_SUFFIX.length);
+ 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;
+ }
+
+ const exportNames = analysis.namedExports;
+ 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 d58b08df3fa2..b330890d9f5d 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';
@@ -19,6 +20,10 @@ export async function sentryReactRouter(
plugins.push(makeConfigInjectorPlugin(options));
+ if (options.experimental_rscAutoInstrumentation?.enabled !== false) {
+ plugins.push(makeAutoInstrumentRSCPlugin(options.experimental_rscAutoInstrumentation ?? {}));
+ }
+
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..45ef383f5fd7 100644
--- a/packages/react-router/src/vite/types.ts
+++ b/packages/react-router/src/vite/types.ts
@@ -74,4 +74,32 @@ 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.
+ * RSC mode is auto-detected when `unstable_reactRouterRSC()` is present in the Vite config.
+ * Use this option to enable debug logging or to explicitly disable with `{ enabled: false }`.
+ * Server components must be wrapped manually using `wrapServerComponent`.
+ */
+ experimental_rscAutoInstrumentation?: AutoInstrumentRSCOptions;
};
+
+/**
+ * Options for the experimental RSC auto-instrumentation Vite plugin.
+ *
+ * RSC mode is auto-detected — no explicit flag is needed. Pass this option only to
+ * customize behavior or to explicitly disable with `{ enabled: false }`.
+ */
+export type AutoInstrumentRSCOptions = {
+ /**
+ * Enable or disable auto-instrumentation of server functions.
+ * @default true
+ */
+ enabled?: boolean;
+
+ /**
+ * Enable debug logging to see which files are being instrumented.
+ * @default false
+ */
+ debug?: 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..630e7d44c062
--- /dev/null
+++ b/packages/react-router/test/server/rsc/responseUtils.test.ts
@@ -0,0 +1,79 @@
+import { addNonEnumerableProperty } from '@sentry/core';
+import { describe, expect, it } from 'vitest';
+import { isAlreadyCaptured, isNotFoundResponse, isRedirectResponse } from '../../../src/server/rsc/responseUtils';
+
+describe('responseUtils', () => {
+ describe('isAlreadyCaptured', () => {
+ it('should return false for errors without __sentry_captured__', () => {
+ expect(isAlreadyCaptured(new Error('test'))).toBe(false);
+ });
+
+ it('should return true for errors with __sentry_captured__ set', () => {
+ const error = new Error('test');
+ addNonEnumerableProperty(error as unknown as Record, '__sentry_captured__', true);
+ expect(isAlreadyCaptured(error)).toBe(true);
+ });
+
+ it('should return false for non-object values', () => {
+ expect(isAlreadyCaptured(null)).toBe(false);
+ expect(isAlreadyCaptured(undefined)).toBe(false);
+ expect(isAlreadyCaptured('string')).toBe(false);
+ expect(isAlreadyCaptured(42)).toBe(false);
+ });
+ });
+
+ 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('should return false for non-object values', () => {
+ expect(isRedirectResponse(null)).toBe(false);
+ expect(isRedirectResponse(undefined)).toBe(false);
+ expect(isRedirectResponse('error')).toBe(false);
+ expect(isRedirectResponse(42)).toBe(false);
+ });
+
+ it('should return false for Error objects', () => {
+ expect(isRedirectResponse(new Error('test'))).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('should return false for non-object values', () => {
+ expect(isNotFoundResponse(null)).toBe(false);
+ expect(isNotFoundResponse(undefined)).toBe(false);
+ expect(isNotFoundResponse('error')).toBe(false);
+ expect(isNotFoundResponse(42)).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..229d32ef0b6e
--- /dev/null
+++ b/packages/react-router/test/server/rsc/wrapServerComponent.test.ts
@@ -0,0 +1,299 @@
+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(),
+ captureException: vi.fn(),
+ flushIfServerless: vi.fn().mockResolvedValue(undefined),
+ SPAN_STATUS_OK: 1,
+ SPAN_STATUS_ERROR: 2,
+ };
+});
+
+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 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 handle a thenable that ignores the error callback gracefully', () => {
+ const thenableResult = {
+ then: (_resolve: (value: unknown) => void) => {},
+ };
+ const mockComponent = vi.fn().mockReturnValue(thenableResult);
+ const mockSetTransactionName = vi.fn();
+
+ (core.getIsolationScope as any).mockReturnValue({
+ setTransactionName: mockSetTransactionName,
+ });
+
+ const wrappedComponent = wrapServerComponent(mockComponent, {
+ componentRoute: '/page',
+ componentType: 'Page',
+ });
+
+ expect(() => wrappedComponent()).not.toThrow();
+ });
+
+ 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();
+
+ // Wait for the promise to resolve so the .then() handler fires
+ 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();
+ });
+
+ it('should preserve function properties via Proxy', () => {
+ const mockComponent = Object.assign(vi.fn().mockReturnValue({ type: 'div' }), {
+ displayName: 'MyComponent',
+ customProp: 'value',
+ });
+ const mockSetTransactionName = vi.fn();
+
+ (core.getIsolationScope as any).mockReturnValue({
+ setTransactionName: mockSetTransactionName,
+ });
+
+ const wrappedComponent = wrapServerComponent(mockComponent, {
+ componentRoute: '/page',
+ componentType: 'Page',
+ });
+
+ expect((wrappedComponent as any).displayName).toBe('MyComponent');
+ expect((wrappedComponent as any).customProp).toBe('value');
+ });
+
+ it('should not double-capture already-captured errors', () => {
+ const mockError = new Error('Already captured error');
+ Object.defineProperty(mockError, '__sentry_captured__', { value: true, enumerable: false });
+
+ 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: '/page',
+ componentType: 'Page',
+ });
+
+ expect(() => wrappedComponent()).toThrow('Already captured error');
+ expect(core.captureException).not.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..6254a432a622
--- /dev/null
+++ b/packages/react-router/test/server/rsc/wrapServerFunction.test.ts
@@ -0,0 +1,221 @@
+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,
+ startSpan: 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.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: 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.startSpan).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.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() }));
+
+ const wrappedFn = wrapServerFunction('testFunction', mockServerFn);
+ await wrappedFn();
+
+ expect(core.startSpan).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.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() }));
+
+ const wrappedFn = wrapServerFunction('testFunction', mockServerFn, {
+ name: 'Custom Span Name',
+ });
+ await wrappedFn();
+
+ expect(mockSetTransactionName).toHaveBeenCalledWith('Custom Span Name');
+ expect(core.startSpan).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.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() }));
+
+ const wrappedFn = wrapServerFunction('testFunction', mockServerFn, {
+ attributes: { 'custom.attr': 'value' },
+ });
+ await wrappedFn();
+
+ expect(core.startSpan).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 mockSetTransactionName = vi.fn();
+
+ (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName });
+ (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus }));
+
+ const wrappedFn = wrapServerFunction('testFunction', mockServerFn);
+
+ await expect(wrappedFn()).rejects.toThrow('Server function failed');
+ expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' });
+ expect(core.captureException).toHaveBeenCalledWith(mockError, {
+ mechanism: {
+ type: 'react_router.rsc',
+ handled: false,
+ data: {
+ function: 'serverFunction',
+ server_function_name: 'testFunction',
+ },
+ },
+ });
+ 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 mockSetTransactionName = vi.fn();
+
+ (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName });
+ (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus }));
+
+ const wrappedFn = wrapServerFunction('testFunction', mockServerFn);
+
+ await expect(wrappedFn()).rejects.toBe(redirectResponse);
+ expect(mockSetStatus).toHaveBeenCalledWith({ code: 1 });
+ 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 mockSetTransactionName = vi.fn();
+
+ (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName });
+ (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: mockSetStatus }));
+
+ const wrappedFn = wrapServerFunction('testFunction', mockServerFn);
+
+ await expect(wrappedFn()).rejects.toBe(notFoundResponse);
+ expect(mockSetStatus).toHaveBeenCalledWith({ code: 2, message: 'not_found' });
+ expect(core.captureException).not.toHaveBeenCalled();
+ });
+
+ it('should preserve function properties via Proxy', () => {
+ const namedServerFn = Object.assign(
+ async function myServerAction(): Promise {
+ return 'result';
+ },
+ { customProp: 'value' },
+ );
+ const wrappedFn = wrapServerFunction('myServerAction', namedServerFn);
+
+ // Proxy should preserve original function name and properties
+ expect(wrappedFn.name).toBe('myServerAction');
+ expect((wrappedFn as any).customProp).toBe('value');
+ });
+
+ 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.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() }));
+
+ const wrappedFn = wrapServerFunction('testFunction', mockServerFn);
+
+ await expect(wrappedFn()).rejects.toBe(mockError);
+ });
+
+ it('should not double-capture already-captured errors', async () => {
+ const mockError = new Error('Already captured error');
+ // Mark the error as already captured by Sentry
+ Object.defineProperty(mockError, '__sentry_captured__', { value: true, enumerable: false });
+
+ const mockServerFn = vi.fn().mockRejectedValue(mockError);
+ const mockSetTransactionName = vi.fn();
+
+ (core.getIsolationScope as any).mockReturnValue({ setTransactionName: mockSetTransactionName });
+ (core.startSpan as any).mockImplementation((_: any, fn: any) => fn({ setStatus: vi.fn() }));
+
+ const wrappedFn = wrapServerFunction('testFunction', mockServerFn);
+
+ await expect(wrappedFn()).rejects.toBe(mockError);
+ // captureException should NOT be called since the error is already captured
+ expect(core.captureException).not.toHaveBeenCalled();
+ });
+});
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..4e116ac8de3f
--- /dev/null
+++ b/packages/react-router/test/vite/makeAutoInstrumentRSCPlugin.test.ts
@@ -0,0 +1,534 @@
+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('returns null for server components (no "use server" directive)', () => {
+ const plugin = createPluginWithRSCDetected();
+ expect(plugin.transform('export default function Page() {}', 'app/routes/home.tsx')).toBeNull();
+ });
+
+ it('returns null for "use client" files', () => {
+ const plugin = createPluginWithRSCDetected();
+ const code = "'use client';\nexport default function ClientComponent() {}";
+ expect(plugin.transform(code, 'app/routes/client.tsx')).toBeNull();
+ });
+
+ it('returns null for files without directives or exports', () => {
+ const plugin = createPluginWithRSCDetected();
+ expect(plugin.transform("export function helper() { return 'helper'; }", 'app/routes/utils.tsx')).toBeNull();
+ });
+
+ // Server function auto-instrumentation ("use server" files)
+ 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)');
+ });
+
+ // Regression: ensures export class declarations are collected by the AST parser.
+ // While exporting a class from "use server" is uncommon, the plugin should handle
+ // it without crashing rather than silently skipping the export.
+ 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"');
+ });
+
+ // Regression: export default async function name should be treated as default, not named
+ 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('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', () => {
+ // Named export extraction
+ it('extracts export const declarations', () => {
+ const code = "export const submitForm = wrapServerFunction('submitForm', _submitForm);";
+ expect(analyzeModule(code)?.namedExports).toEqual(['submitForm']);
+ });
+
+ it('extracts export function declarations', () => {
+ const code = 'export function submitForm(data) { return data; }';
+ expect(analyzeModule(code)?.namedExports).toEqual(['submitForm']);
+ });
+
+ it('extracts export async function declarations', () => {
+ const code = 'export async function fetchData() { return await fetch("/api"); }';
+ expect(analyzeModule(code)?.namedExports).toEqual(['fetchData']);
+ });
+
+ 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('ignores inline type exports in export { type X }', () => {
+ const code = 'type Foo = string;\ntype Baz = number;\nconst bar = 1;\nexport { type Foo, bar, type Baz as Qux }';
+ expect(analyzeModule(code)?.namedExports).toEqual(['bar']);
+ });
+
+ it('ignores type-only export specifiers mixed with regular exports', () => {
+ const code = ['type MyType = string;', 'const a = 1;', 'const b = 2;', 'export { type MyType, a, b }'].join('\n');
+ expect(analyzeModule(code)?.namedExports).toEqual(['a', 'b']);
+ });
+
+ it('extracts export class declarations', () => {
+ expect(analyzeModule('export class MyClass {}')?.namedExports).toEqual(['MyClass']);
+ });
+
+ // Regression test: export default async function does not appear as named export
+ 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);
+ });
+
+ // Directive detection
+ 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);
+ });
+
+ // Default export detection
+ 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);
+ });
+
+ // Manual wrapping detection
+ 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);
+ });
+
+ // Parse failure
+ 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('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 f01254ca8869..227f6685a89f 100644
--- a/packages/react-router/test/vite/plugin.test.ts
+++ b/packages/react-router/test/vite/plugin.test.ts
@@ -37,7 +37,8 @@ describe('sentryReactRouter', () => {
const result = await sentryReactRouter({}, { command: 'build', mode: 'production' });
- expect(result).toEqual([mockConfigInjectorPlugin]);
+ expect(result).toHaveLength(2);
+ expect(result).toContainEqual(mockConfigInjectorPlugin);
expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled();
expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled();
@@ -47,7 +48,8 @@ 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]);
+ expect(result).toHaveLength(2);
+ expect(result).toContainEqual(mockConfigInjectorPlugin);
expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled();
expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled();
});
@@ -55,7 +57,8 @@ 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]);
+ expect(result).toHaveLength(2);
+ expect(result).toContainEqual(mockConfigInjectorPlugin);
expect(makeCustomSentryVitePlugins).not.toHaveBeenCalled();
expect(makeEnableSourceMapsPlugin).not.toHaveBeenCalled();
});
@@ -66,7 +69,10 @@ describe('sentryReactRouter', () => {
const result = await sentryReactRouter({}, { command: 'build', mode: 'production' });
- expect(result).toEqual([mockConfigInjectorPlugin, mockSourceMapsPlugin, ...mockPlugins]);
+ expect(result).toHaveLength(4);
+ expect(result).toContainEqual(mockConfigInjectorPlugin);
+ expect(result).toContainEqual(mockSourceMapsPlugin);
+ expect(result).toContainEqual(mockPlugins[0]);
expect(makeConfigInjectorPlugin).toHaveBeenCalledWith({});
expect(makeCustomSentryVitePlugins).toHaveBeenCalledWith({});
expect(makeEnableSourceMapsPlugin).toHaveBeenCalledWith({});
@@ -74,6 +80,22 @@ describe('sentryReactRouter', () => {
process.env.NODE_ENV = originalNodeEnv;
});
+ it('should always include RSC auto-instrument plugin by default', async () => {
+ const result = await sentryReactRouter({}, { 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(1);
+ 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';