From 436f96955f7a732531b520c65f9546aaf743a489 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 11:29:00 +0200 Subject: [PATCH 1/2] test(e2e): Add a static trace lifecycle React Router E2E app Copies `react-router-7-framework` into `react-router-7-framework-static`, which keeps `traceLifecycle: 'static'` and its transaction-based specs. The rest of the React Router group moves to span streaming in the PRs above, so this copy is what keeps the static lifecycle covered. The copy drops the `latest` build variant, so it costs one CI job rather than two. --- .../.gitignore | 32 +++ .../app/app.css | 6 + .../app/context.ts | 8 + .../app/entry.client.tsx | 24 ++ .../app/entry.server.tsx | 18 ++ .../app/root.tsx | 67 +++++ .../app/routes.ts | 24 ++ .../app/routes/errors/client-action.tsx | 18 ++ .../app/routes/errors/client-loader.tsx | 16 ++ .../app/routes/errors/client-param.tsx | 17 ++ .../app/routes/errors/client.tsx | 15 ++ .../app/routes/errors/server-action.tsx | 18 ++ .../app/routes/errors/server-loader.tsx | 16 ++ .../app/routes/home.tsx | 9 + .../app/routes/performance/dynamic-param.tsx | 17 ++ .../app/routes/performance/index.tsx | 17 ++ .../app/routes/performance/redis.tsx | 22 ++ .../app/routes/performance/server-action.tsx | 24 ++ .../app/routes/performance/server-loader.tsx | 16 ++ .../app/routes/performance/ssr.tsx | 14 + .../app/routes/performance/static.tsx | 3 + .../routes/performance/with-middleware.tsx | 38 +++ .../app/routes/sentry-flush.tsx | 6 + .../docker-compose.yml | 12 + .../global-setup.mjs | 21 ++ .../instrument.mjs | 9 + .../package.json | 59 +++++ .../playwright.config.mjs | 13 + .../public/favicon.ico | Bin 0 -> 15086 bytes .../react-router.config.ts | 9 + .../start-event-proxy.mjs | 6 + .../tests/constants.ts | 1 + .../tests/errors/errors.client.test.ts | 140 ++++++++++ .../tests/errors/errors.server.test.ts | 100 +++++++ .../low-quality-filter.server.test.ts | 34 +++ .../performance/middleware.server.test.ts | 38 +++ .../performance/navigation.client.test.ts | 243 ++++++++++++++++++ .../tests/performance/pageload.client.test.ts | 148 +++++++++++ .../performance/performance.server.test.ts | 107 ++++++++ .../tests/performance/redis.server.test.ts | 35 +++ .../performance/trace-propagation.test.ts | 47 ++++ .../tsconfig.json | 20 ++ .../vite.config.ts | 6 + 43 files changed, 1493 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/app.css create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/context.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.server.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-action.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-loader.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-param.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-action.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-loader.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/home.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/dynamic-param.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/redis.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-action.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-loader.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/ssr.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/static.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/with-middleware.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/sentry-flush.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/public/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/react-router.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/constants.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/low-quality-filter.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/middleware.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/navigation.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/pageload.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/performance.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/redis.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-framework-static/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/.gitignore new file mode 100644 index 000000000000..ebb991370034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/.gitignore @@ -0,0 +1,32 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts + +# react router +.react-router diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/app.css b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/app.css new file mode 100644 index 000000000000..b31c3a9d0ddf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/app.css @@ -0,0 +1,6 @@ +html, +body { + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/context.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/context.ts new file mode 100644 index 000000000000..a15189e5bed8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react-router'; + +export type User = { + id: string; + name: string; +}; + +export const userContext = createContext(null); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.client.tsx new file mode 100644 index 000000000000..d4a0e1d0900b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.client.tsx @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/react-router'; +import { StrictMode, startTransition } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { HydratedRouter } from 'react-router/dom'; + +Sentry.init({ + traceLifecycle: 'static', + environment: 'qa', // dynamic sampling bias to keep transactions + // todo: get this from env + dsn: 'https://username@domain/123', + tunnel: `http://localhost:3031/`, // proxy server + integrations: [Sentry.reactRouterTracingIntegration()], + tracesSampleRate: 1.0, + tracePropagationTargets: [/^\//], +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/entry.server.tsx new file mode 100644 index 000000000000..738cd1515a4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/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-framework-static/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/root.tsx new file mode 100644 index 000000000000..bc1b8f1236c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/root.tsx @@ -0,0 +1,67 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router'; +import type { Route } from './+types/root'; +import stylesheet from './app.css?url'; + +export const links: Route.LinksFunction = () => [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', + }, + { rel: 'stylesheet', href: stylesheet }, +]; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!'; + let details = 'An unexpected error occurred.'; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error'; + details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; + } else if (error && error instanceof Error) { + if (import.meta.env.DEV) { + details = error.message; + stack = error.stack; + } + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes.ts new file mode 100644 index 000000000000..3e3839295b1d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes.ts @@ -0,0 +1,24 @@ +import { type RouteConfig, index, prefix, route } from '@react-router/dev/routes'; + +export default [ + index('routes/home.tsx'), + route('__sentry-flush', 'routes/sentry-flush.tsx'), + ...prefix('errors', [ + route('client', 'routes/errors/client.tsx'), + route('client/:client-param', 'routes/errors/client-param.tsx'), + route('client-loader', 'routes/errors/client-loader.tsx'), + route('server-loader', 'routes/errors/server-loader.tsx'), + route('client-action', 'routes/errors/client-action.tsx'), + route('server-action', 'routes/errors/server-action.tsx'), + ]), + ...prefix('performance', [ + index('routes/performance/index.tsx'), + route('ssr', 'routes/performance/ssr.tsx'), + route('with/:param', 'routes/performance/dynamic-param.tsx'), + route('static', 'routes/performance/static.tsx'), + route('server-loader', 'routes/performance/server-loader.tsx'), + route('server-action', 'routes/performance/server-action.tsx'), + route('with-middleware', 'routes/performance/with-middleware.tsx'), + route('redis', 'routes/performance/redis.tsx'), + ]), +] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-action.tsx new file mode 100644 index 000000000000..d3b2d08eef2e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-action.tsx @@ -0,0 +1,18 @@ +import { Form } from 'react-router'; + +export function clientAction() { + throw new Error('Madonna mia! Che casino nella Client Action!'); +} + +export default function ClientActionErrorPage() { + return ( +
+

Client Error Action Page

+
+ +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-loader.tsx new file mode 100644 index 000000000000..72d9e62a99dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export function clientLoader() { + throw new Error('¡Madre mía del client loader!'); + return { data: 'sad' }; +} + +export default function ClientLoaderErrorPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Client Loader Error Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-param.tsx new file mode 100644 index 000000000000..a2e423391f03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client-param.tsx @@ -0,0 +1,17 @@ +import type { Route } from './+types/client-param'; + +export default function ClientErrorParamPage({ params }: Route.ComponentProps) { + return ( +
+

Client Error Param Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client.tsx new file mode 100644 index 000000000000..190074a5ef09 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/client.tsx @@ -0,0 +1,15 @@ +export default function ClientErrorPage() { + return ( +
+

Client Error Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-action.tsx new file mode 100644 index 000000000000..863c320f3557 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-action.tsx @@ -0,0 +1,18 @@ +import { Form } from 'react-router'; + +export function action() { + throw new Error('Madonna mia! Che casino nella Server Action!'); +} + +export default function ServerActionErrorPage() { + return ( +
+

Server Error Action Page

+
+ +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-loader.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-loader.tsx new file mode 100644 index 000000000000..cb777686d540 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/errors/server-loader.tsx @@ -0,0 +1,16 @@ +import type { Route } from './+types/server-loader'; + +export function loader() { + throw new Error('¡Madre mía del server!'); + return { data: 'sad' }; +} + +export default function ServerLoaderErrorPage({ loaderData }: Route.ComponentProps) { + const { data } = loaderData; + return ( +
+

Server Error Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/home.tsx new file mode 100644 index 000000000000..4498e7a0d017 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/home.tsx @@ -0,0 +1,9 @@ +import type { Route } from './+types/home'; + +export function meta({}: Route.MetaArgs) { + return [{ title: 'New React Router App' }, { name: 'description', content: 'Welcome to React Router!' }]; +} + +export default function Home() { + return
home
; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/dynamic-param.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/dynamic-param.tsx new file mode 100644 index 000000000000..1ac02775f2ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/dynamic-param.tsx @@ -0,0 +1,17 @@ +import type { Route } from './+types/dynamic-param'; + +export async function loader() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { data: 'burritos' }; +} + +export default function DynamicParamPage({ params }: Route.ComponentProps) { + const { param } = params; + + return ( +
+

Dynamic Parameter Page

+

The parameter value is: {param}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/index.tsx new file mode 100644 index 000000000000..4c086645603e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/index.tsx @@ -0,0 +1,17 @@ +import { Link } from 'react-router'; + +export default function PerformancePage() { + return ( +
+

Performance Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/redis.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/redis.tsx new file mode 100644 index 000000000000..cba8275fcf63 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/redis.tsx @@ -0,0 +1,22 @@ +import Redis from 'ioredis'; +import type { Route } from './+types/redis'; + +const redis = new Redis(); + +export async function loader() { + const key = 'cache:greeting'; + await redis.set(key, 'hello from react-router'); + const value = await redis.get(key); + + return { value }; +} + +export default function RedisPage({ loaderData }: Route.ComponentProps) { + const { value } = loaderData; + return ( +
+

Redis Page

+
{value}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-action.tsx new file mode 100644 index 000000000000..462fc6fbf54c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/server-action.tsx @@ -0,0 +1,24 @@ +import { Form } from 'react-router'; +import type { Route } from './+types/server-action'; + +export async function action({ request }: Route.ActionArgs) { + let formData = await request.formData(); + let name = formData.get('name'); + await new Promise(resolve => setTimeout(resolve, 1000)); + return { + greeting: `Hola ${name}`, + }; +} + +export default function Project({ actionData }: Route.ComponentProps) { + return ( +
+

Server action page

+
+ + +
+ {actionData ?

{actionData.greeting}

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

Server Loader Page

+
{data}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/ssr.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/ssr.tsx new file mode 100644 index 000000000000..8226e68f3be0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/ssr.tsx @@ -0,0 +1,14 @@ +import { useNavigate } from 'react-router'; + +export default function SsrPage() { + const navigate = useNavigate(); + + return ( +
+

SSR Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/static.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/static.tsx new file mode 100644 index 000000000000..3dea24381fdc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/static.tsx @@ -0,0 +1,3 @@ +export default function StaticPage() { + return

Static Page

; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/with-middleware.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/with-middleware.tsx new file mode 100644 index 000000000000..c86f78e17164 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/performance/with-middleware.tsx @@ -0,0 +1,38 @@ +import type { Route } from './+types/with-middleware'; +import type { User } from '../../context'; +import { userContext } from '../../context'; +import * as Sentry from '@sentry/react-router'; + +async function getUser() { + await new Promise(resolve => setTimeout(resolve, 500)); + return { + id: '1', + name: 'Carlos Gomez', + }; +} + +const authMiddleware: Route.MiddlewareFunction = async ({ request, context }, next) => { + Sentry.startSpan({ name: 'authMiddleware', op: 'middleware.auth' }, async () => { + const user: User = await getUser(); + context.set(userContext, user); + await next(); + }); +}; + +export const middleware: Route.MiddlewareFunction[] = [authMiddleware]; + +export const loader = async ({ context }: Route.LoaderArgs) => { + const user = context.get(userContext); + return { user }; +}; + +export default function WithMiddlewarePage({ loaderData }: Route.ComponentProps) { + const { user } = loaderData; + + return ( +
+

With Middleware Page

+

User: {user?.name}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/sentry-flush.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/sentry-flush.tsx new file mode 100644 index 000000000000..c72024185046 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/app/routes/sentry-flush.tsx @@ -0,0 +1,6 @@ +import * as Sentry from '@sentry/react-router'; + +export async function loader() { + await Sentry.flush(2000); + return new Response(null, { status: 204 }); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/docker-compose.yml b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/docker-compose.yml new file mode 100644 index 000000000000..d2193ccad07b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/docker-compose.yml @@ -0,0 +1,12 @@ +services: + redis: + image: redis:8 + restart: always + container_name: e2e-tests-react-router-7-static-redis + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 1s + timeout: 3s + retries: 30 diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/global-setup.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/global-setup.mjs new file mode 100644 index 000000000000..1e6210bfdc35 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/global-setup.mjs @@ -0,0 +1,21 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Boot Redis here (rather than in the `start` script) so the cold `redis:8` image +// pull happens outside Playwright's webServer startup-timeout window. `--wait` +// blocks until the healthcheck passes; +export default async function globalSetup() { + // Each run copies this app to a fresh temp dir, so `docker compose` doesn't + // recognize a leftover container from a previous (e.g. interrupted) run as + // part of the same project - but the container name is fixed, so the daemon + // still refuses to create a new one. Force-remove any stale leftover first. + try { + execSync('docker rm -f e2e-tests-react-router-7-static-redis', { stdio: 'ignore' }); + } catch { + // no stale container to remove + } + execSync('docker compose up -d --wait', { cwd: __dirname, stdio: 'inherit' }); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/instrument.mjs new file mode 100644 index 000000000000..00a6d2952286 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/react-router'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://username@domain/123', + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, + tunnel: `http://localhost:3031/`, // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json new file mode 100644 index 000000000000..b17cf84b46c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/package.json @@ -0,0 +1,59 @@ +{ + "name": "react-router-7-framework-static", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.13.0", + "@react-router/node": "^7.13.0", + "@react-router/serve": "^7.13.0", + "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", + "ioredis": "^5.4.1", + "isbot": "^5.1.17" + }, + "devDependencies": { + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "@types/node": "^20", + "@react-router/dev": "^7.13.0", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.6.3", + "vite": "^5.4.11" + }, + "scripts": { + "build": "react-router build", + "dev": "NODE_OPTIONS='--import ./instrument.mjs' react-router dev", + "start": "NODE_OPTIONS='--import ./instrument.mjs' react-router-serve ./build/server/index.js", + "proxy": "node start-event-proxy.mjs", + "typecheck": "react-router typegen && tsc", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:ts && pnpm test:playwright", + "test:ts": "pnpm typecheck", + "test:playwright": "playwright test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/playwright.config.mjs new file mode 100644 index 000000000000..70e62c2b9e3b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/playwright.config.mjs @@ -0,0 +1,13 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { fileURLToPath } from 'url'; + +const config = getPlaywrightConfig( + { + startCommand: `PORT=3030 pnpm start`, + port: 3030, + }, + // Boot Redis before the tests run, outside the webServer startup-timeout window. + { globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)) }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/public/favicon.ico b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5dbdfcddcb14182535f6d32d1c900681321b1aa3 GIT binary patch literal 15086 zcmeI33v3ic7{|AFEmuJ-;v>ep_G*NPi6KM`qNryCe1PIJ8siIN1WZ(7qVa)RVtmC% z)Ch?tN+afMKm;5@rvorJk zcXnoOc4q51HBQnQH_jn!cAg&XI1?PlX>Kl^k8qq0;zkha`kY$Fxt#=KNJAE9CMdpW zqr4#g8`nTw191(+H4xW8Tmyru2I^3=J1G3emPxkPXA=3{vvuvse_WWSshqaqls^-m zgB7q8&Vk*aYRe?sn$n53dGH#%3y%^vxv{pL*-h0Z4bmb_(k6{FL7HWIz(V*HT#IcS z-wE{)+0x1U!RUPt3gB97%p}@oHxF4|6S*+Yw=_tLtxZ~`S=z6J?O^AfU>7qOX`JNBbV&8+bO0%@fhQitKIJ^O^ zpgIa__qD_y07t@DFlBJ)8SP_#^j{6jpaXt{U%=dx!qu=4u7^21lWEYHPPY5U3TcoQ zX_7W+lvZi>TapNk_X>k-KO%MC9iZp>1E`N34gHKd9tK&){jq2~7OsJ>!G0FzxQFw6G zm&Vb(2#-T|rM|n3>uAsG_hnbvUKFf3#ay@u4uTzia~NY%XgCHfx4^To4BDU@)HlV? z@EN=g^ymETa1sQK{kRwyE4Ax8?wT&GvaG@ASO}{&a17&^v`y z!oPdiSiia^oov(Z)QhG2&|FgE{M9_4hJROGbnj>#$~ZF$-G^|zPj*QApltKe?;u;uKHJ~-V!=VLkg7Kgct)l7u39f@%VG8e3f$N-B zAu3a4%ZGf)r+jPAYCSLt73m_J3}p>}6Tx0j(wg4vvKhP!DzgiWANiE;Ppvp}P2W@m z-VbYn+NXFF?6ngef5CfY6ZwKnWvNV4z6s^~yMXw2i5mv}jC$6$46g?G|CPAu{W5qF zDobS=zb2ILX9D827g*NtGe5w;>frjanY{f)hrBP_2ehBt1?`~ypvg_Ot4x1V+43P@Ve8>qd)9NX_jWdLo`Zfy zoeam9)@Dpym{4m@+LNxXBPjPKA7{3a&H+~xQvr>C_A;7=JrfK~$M2pCh>|xLz>W6SCs4qC|#V`)# z)0C|?$o>jzh<|-cpf

K7osU{Xp5PG4-K+L2G=)c3f&}H&M3wo7TlO_UJjQ-Oq&_ zjAc9=nNIYz{c3zxOiS5UfcE1}8#iI4@uy;$Q7>}u`j+OU0N<*Ezx$k{x_27+{s2Eg z`^=rhtIzCm!_UcJ?Db~Lh-=_))PT3{Q0{Mwdq;0>ZL%l3+;B&4!&xm#%HYAK|;b456Iv&&f$VQHf` z>$*K9w8T+paVwc7fLfMlhQ4)*zL_SG{~v4QR;IuX-(oRtYAhWOlh`NLoX0k$RUYMi z2Y!bqpdN}wz8q`-%>&Le@q|jFw92ErW-hma-le?S z-@OZt2EEUm4wLsuEMkt4zlyy29_3S50JAcQHTtgTC{P~%-mvCTzrjXOc|{}N`Cz`W zSj7CrXfa7lcsU0J(0uSX6G`54t^7}+OLM0n(|g4waOQ}bd3%!XLh?NX9|8G_|06Ie zD5F1)w5I~!et7lA{G^;uf7aqT`KE&2qx9|~O;s6t!gb`+zVLJyT2T)l*8l(j literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/react-router.config.ts new file mode 100644 index 000000000000..72f2eef3b0f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/react-router.config.ts @@ -0,0 +1,9 @@ +import type { Config } from '@react-router/dev/config'; + +export default { + ssr: true, + prerender: ['/performance/static'], + future: { + v8_middleware: true, + }, +} satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/start-event-proxy.mjs new file mode 100644 index 000000000000..9efc50b2c508 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-framework-static', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/constants.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/constants.ts new file mode 100644 index 000000000000..e0420ef6daf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/constants.ts @@ -0,0 +1 @@ +export const APP_NAME = 'react-router-7-framework-static'; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.client.test.ts new file mode 100644 index 000000000000..c1a7de46f1b6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.client.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client-side errors', () => { + const errorMessage = '¡Madre mía!'; + test('captures error thrown on click', async ({ page }) => { + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/client`); + await page.locator('#throw-on-click').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + }, + }, + ], + }, + transaction: '/errors/client', + request: { + url: expect.stringContaining('errors/client'), + headers: expect.any(Object), + }, + level: 'error', + platform: 'javascript', + environment: 'qa', + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.react-router', + version: expect.any(String), + }, + tags: { runtime: 'browser' }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + breadcrumbs: [ + { + category: 'ui.click', + message: 'body > div > button#throw-on-click', + }, + ], + }); + }); + + test('captures error thrown on click from a parameterized route', async ({ page }) => { + const errorMessage = '¡Madre mía de churros!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client/churros'); + await page.locator('#throw-on-click').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: '¡Madre mía de churros!', + mechanism: { + handled: false, + }, + }, + ], + }, + // todo: should be '/errors/client/:client-param' + transaction: '/errors/client/churros', + }); + }); + + test('captures error thrown in a clientLoader', async ({ page }) => { + const errorMessage = '¡Madre mía del client loader!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client-loader'); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'auto.function.react_router.on_error', + }, + }, + ], + }, + transaction: '/errors/client-loader', + }); + }); + + test('captures error thrown in a clientAction', async ({ page }) => { + const errorMessage = 'Madonna mia! Che casino nella Client Action!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto('/errors/client-action'); + await page.locator('#submit').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'auto.function.react_router.on_error', + }, + }, + ], + }, + transaction: '/errors/client-action', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.server.test.ts new file mode 100644 index 000000000000..2759bfecb67e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/errors/errors.server.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server-side errors', () => { + test('captures error thrown in server loader', async ({ page }) => { + const errorMessage = '¡Madre mía del server!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/server-loader`); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'react-router', + }, + }, + ], + }, + // todo: should be 'GET /errors/server-loader' + transaction: 'GET *', + request: { + url: expect.stringContaining('errors/server-loader'), + headers: expect.any(Object), + }, + 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('captures error thrown in server action', async ({ page }) => { + const errorMessage = 'Madonna mia! Che casino nella Server Action!'; + const errorPromise = waitForError(APP_NAME, async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === errorMessage; + }); + + await page.goto(`/errors/server-action`); + await page.locator('#submit').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: errorMessage, + mechanism: { + handled: false, + type: 'react-router', + }, + }, + ], + }, + // todo: should be 'POST /errors/server-action' + transaction: 'POST *', + request: { + url: expect.stringContaining('errors/server-action'), + headers: expect.any(Object), + }, + 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), + }, + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/low-quality-filter.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/low-quality-filter.server.test.ts new file mode 100644 index 000000000000..0664ac5c99e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/low-quality-filter.server.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('low-quality transaction filter', () => { + test('does not send a server transaction for /__manifest? requests', async ({ page }) => { + const serverTxns: Array<{ contexts?: { trace?: { data?: Record } } }> = []; + + const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + waitForTransaction(APP_NAME, async evt => { + serverTxns.push(evt); + return false; + }); + + await page.goto('/performance'); + await page.waitForTimeout(1000); + await page.getByRole('link', { name: 'SSR Page' }).click(); + + await navigationPromise; + + // Force the server to flush any in-flight transactions before we assert + await page.evaluate(() => fetch('/__sentry-flush')); + + const targetIsManifest = (t: (typeof serverTxns)[number]) => + typeof t.contexts?.trace?.data?.['url.path'] === 'string' && + (t.contexts.trace.data['url.path'] as string).includes('/__manifest'); + expect(serverTxns.some(targetIsManifest)).toBe(false); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/middleware.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/middleware.server.test.ts new file mode 100644 index 000000000000..dbce05350ad9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/middleware.server.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server - middleware', () => { + test('should send middleware transaction on pageload', async ({ page }) => { + const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with-middleware'; + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/with-middleware'; + }); + + const customMiddlewareTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'authMiddleware'; + }); + + await page.goto(`/performance/with-middleware`); + + const serverTx = await serverTxPromise; + const pageloadTx = await pageloadTxPromise; + const customMiddlewareTx = await customMiddlewareTxPromise; + + const traceIds = { + server: serverTx?.contexts?.trace?.trace_id, + pageload: pageloadTx?.contexts?.trace?.trace_id, + customMiddleware: customMiddlewareTx?.contexts?.trace?.trace_id, + }; + + expect(pageloadTx).toBeDefined(); + expect(customMiddlewareTx).toBeDefined(); + + // Assert that all transactions belong to the same trace + expect(traceIds.server).toBe(traceIds.pageload); + expect(traceIds.server).toBe(traceIds.customMiddleware); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/navigation.client.test.ts new file mode 100644 index 000000000000..59b2f8a9e038 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/navigation.client.test.ts @@ -0,0 +1,243 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client - navigation performance', () => { + test('should create navigation transaction', async ({ page }) => { + const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); // pageload + await pageloadTxPromise; + await page.getByRole('link', { name: 'SSR Page' }).click(); // navigation + + const transaction = await navigationPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.navigation.react_router', + 'sentry.op': 'navigation', + 'sentry.segment.name.source': 'route', + 'url.template': '/performance/ssr', + 'url.path': '/performance/ssr', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/ssr$/), + }, + op: 'navigation', + origin: 'auto.navigation.react_router', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/ssr', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'javascript', + request: { + url: expect.stringContaining('/performance/ssr'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + test('should create navigation transaction when navigating with object `to` prop', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); // pageload + await pageloadTxPromise; + await page.getByRole('link', { name: 'Object Navigate' }).click(); // navigation with object to + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react_router', + data: { + 'sentry.segment.name.source': 'route', + 'url.template': '/performance/with/:param', + 'url.path': '/performance/with/object-nav', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/object-nav\?foo=bar$/), + }, + }, + }, + transaction: '/performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + }); + }); + + test('should create navigation transaction when navigating with search-only object `to` prop', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); // pageload + await pageloadTxPromise; + await page.getByRole('link', { name: 'Search Only Navigate' }).click(); // navigation with search-only object to + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react_router', + data: { + 'url.template': '/performance', + // the initial pageload to `/performance` gets 301-redirected to a trailing slash by react-router-serve + 'url.path': '/performance/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/\?query=test$/), + }, + }, + }, + transaction: '/performance', + type: 'transaction', + }); + }); + + test('should update navigation transaction for dynamic routes', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); // pageload + await pageloadTxPromise; + await page.getByRole('link', { name: 'With Param Page' }).click(); // navigation + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.navigation.react_router', + 'sentry.op': 'navigation', + 'sentry.segment.name.source': 'route', + 'url.template': '/performance/with/:param', + 'url.path': '/performance/with/sentry', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), + }, + op: 'navigation', + origin: 'auto.navigation.react_router', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'javascript', + request: { + url: expect.stringContaining('/performance/with/sentry'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + test('should create navigation transaction for navigate(-1) with correct url attributes', async ({ page }) => { + const pageloadTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); + await pageloadTxPromise; + + const forwardNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + await page.getByRole('link', { name: 'SSR Page' }).click(); + await forwardNavPromise; + + const backNavPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.getByRole('button', { name: 'History Back Navigate' }).click(); + + const transaction = await backNavPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react_router', + data: { + 'sentry.segment.name.source': 'route', + 'sentry.op': 'navigation', + 'sentry.origin': 'auto.navigation.react_router', + 'url.template': '/performance', + // react-router-serve 301-redirects the bare index route to a trailing slash + 'url.path': '/performance/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), + }, + }, + }, + transaction: '/performance', + type: 'transaction', + transaction_info: { source: 'route' }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/pageload.client.test.ts new file mode 100644 index 000000000000..e3bea05a36ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/pageload.client.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('client - pageload performance', () => { + test('should send pageload transaction', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.pageload.react_router', + 'sentry.op': 'pageload', + 'sentry.segment.name.source': 'route', + 'url.template': '/performance', + // react-router-serve 301-redirects the bare index route to a trailing slash + 'url.path': '/performance/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/$/), + }, + op: 'pageload', + origin: 'auto.pageload.react_router', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance', + type: 'transaction', + transaction_info: { source: 'route' }, + measurements: expect.any(Object), + platform: 'javascript', + request: { + url: expect.stringContaining('/performance'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + test('should update pageload transaction for dynamic routes', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/performance/with/sentry`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.pageload.react_router', + 'sentry.op': 'pageload', + 'sentry.segment.name.source': 'route', + 'url.template': '/performance/with/:param', + 'url.path': '/performance/with/sentry', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/with\/sentry$/), + }, + op: 'pageload', + origin: 'auto.pageload.react_router', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + measurements: expect.any(Object), + platform: 'javascript', + request: { + url: expect.stringContaining('/performance/with/sentry'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + tags: { runtime: 'browser' }, + }); + }); + + test('should send pageload transaction for prerendered pages', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === '/performance/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/performance/static`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + transaction: '/performance/static', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.pageload.react_router', + 'sentry.op': 'pageload', + 'sentry.segment.name.source': 'route', + 'url.template': '/performance/static', + // react-router-serve 301-redirects prerendered routes to a trailing slash + 'url.path': '/performance/static/', + 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/performance\/static\/$/), + }, + op: 'pageload', + origin: 'auto.pageload.react_router', + }, + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/performance.server.test.ts new file mode 100644 index 000000000000..bcfe15e49c34 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/performance.server.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server - performance', () => { + test('should send server transaction on pageload', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance'; + }); + + await page.goto(`/performance`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.request_handler', + 'sentry.segment.name.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.request_handler', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /performance', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'node', + request: { + url: expect.stringContaining('/performance'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/node', version: expect.any(String) }, + ], + }, + tags: { + runtime: 'node', + }, + }); + }); + + test('should send server transaction on parameterized route', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET /performance/with/:param'; + }); + + await page.goto(`/performance/with/some-param`); + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react_router.request_handler', + 'sentry.segment.name.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.react_router.request_handler', + }, + }, + spans: expect.any(Array), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + platform: 'node', + request: { + url: expect.stringContaining('/performance/with/some-param'), + headers: expect.any(Object), + }, + event_id: expect.any(String), + environment: 'qa', + sdk: { + integrations: expect.arrayContaining([expect.any(String)]), + name: 'sentry.javascript.react-router', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/react-router', version: expect.any(String) }, + { name: 'npm:@sentry/node', version: expect.any(String) }, + ], + }, + tags: { + runtime: 'node', + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/redis.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/redis.server.test.ts new file mode 100644 index 000000000000..1b82a709ab7b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/redis.server.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('server - redis db spans', () => { + test('server loader emits db.query child spans on the http.server transaction', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return ( + transactionEvent.transaction === 'GET /performance/redis' && + (transactionEvent.spans?.some(span => span.op === 'db.query') ?? false) + ); + }); + + await page.goto('/performance/redis'); + + const transaction = await txPromise; + + expect(transaction.contexts?.trace?.op).toBe('http.server'); + + // Collect every span id in the transaction (root + children) so we can verify nesting. + const rootSpanId = transaction.contexts?.trace?.span_id; + const spanIds = new Set([rootSpanId, ...(transaction.spans ?? []).map(span => span.span_id)]); + + const redisSpans = transaction.spans!.filter(span => span.op === 'db.query'); + + // loader runs SET then GET => at least two redis command spans + expect(redisSpans.length).toBeGreaterThanOrEqual(2); + + // every redis span nests under the http.server transaction (its parent is part of the same span tree) + const allNested = redisSpans.every( + span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id), + ); + expect(allNested).toBe(true); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts new file mode 100644 index 000000000000..b62e784ff3e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { APP_NAME } from '../constants'; + +test.describe('Trace propagation', () => { + test('should inject metatags in ssr pageload', async ({ page }) => { + await page.goto(`/`); + const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content'); + expect(sentryTraceContent).toBeDefined(); + expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); + const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content'); + expect(baggageContent).toBeDefined(); + expect(baggageContent).toContain('sentry-environment=qa'); + expect(baggageContent).toContain('sentry-public_key='); + expect(baggageContent).toContain('sentry-trace_id='); + expect(baggageContent).toContain('sentry-transaction='); + expect(baggageContent).toContain('sentry-sampled='); + }); + + test('should have trace connection', async ({ page }) => { + const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === 'GET *'; + }); + + const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/'; + }); + + await page.goto(`/`); + const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; + + expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); + + const requestHandlerSpan = serverTx.spans?.find(span => span.op === 'handler'); + + expect(requestHandlerSpan).toBeDefined(); + expect(clientTx.contexts?.trace?.parent_span_id).toBe(requestHandlerSpan?.span_id); + }); + + test('should not have trace connection for prerendered pages', async ({ page }) => { + await page.goto('/performance/static'); + + const sentryTraceElement = await page.$('meta[name="sentry-trace"]'); + expect(sentryTraceElement).toBeNull(); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tsconfig.json new file mode 100644 index 000000000000..a16df276e8bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + }, + "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/vite.config.ts new file mode 100644 index 000000000000..68ba30d69397 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/vite.config.ts @@ -0,0 +1,6 @@ +import { reactRouter } from '@react-router/dev/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [reactRouter()], +}); From 801356c8cacd53eced6950a904bfd2376ec96af3 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 11:29:22 +0200 Subject: [PATCH 2/2] test(e2e): Port React Router v5 and v6 E2E apps to span streaming Removes the `traceLifecycle: 'static'` pin from `react-router-5`, `react-router-6`, `react-router-6-use-routes` and `react-router-6-descendant-routes`, and rewrites their specs against streamed span v2. The `react-router-6` SSE spec needed more than a helper swap: streamed `http.client` names carry only the domain, so the request is matched on `url.full`, and the abort error is read off the console because spans carry no breadcrumbs. All three SSE tests load the same route, so each one also pins its lookup to a pageload span that started after it navigated. --- .../react-router-5/src/index.tsx | 1 - .../react-router-5/tests/errors.test.ts | 14 +- .../react-router-5/tests/spans.test.ts | 50 +++ .../react-router-5/tests/transactions.test.ts | 68 --- .../src/index.tsx | 1 - .../tests/spans.test.ts | 294 +++++++++++++ .../tests/transactions.test.ts | 391 ------------------ .../react-router-6-use-routes/src/index.tsx | 1 - .../tests/errors.test.ts | 14 +- .../tests/spans.test.ts | 50 +++ .../tests/transactions.test.ts | 68 --- .../react-router-6/src/index.tsx | 1 - .../react-router-6/tests/errors.test.ts | 14 +- .../navigation-trace-propagation.test.ts | 18 +- .../{transactions.test.ts => spans.test.ts} | 78 ++-- .../react-router-6/tests/sse.test.ts | 144 ++++--- 16 files changed, 533 insertions(+), 674 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-5/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts rename dev-packages/e2e-tests/test-applications/react-router-6/tests/{transactions.test.ts => spans.test.ts} (57%) diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx index f4fb98246390..cc9b307e620a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-5/src/index.tsx @@ -11,7 +11,6 @@ const replay = Sentry.replayIntegration(); const history = createBrowserHistory(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN || diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts index 96b4a042c08b..17ca866ea821 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-5/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends correct error event', async ({ page }) => { const errorEventPromise = waitForError('react-router-5', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-5', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-5', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-5', event => { @@ -39,9 +39,9 @@ test('Sets correct transactionName', async ({ page }) => { }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - // Only capture error once transaction was sent + // Only capture error once the pageload span was sent const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); @@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => { expect(errorEvent.transaction).toEqual('/'); expect(errorEvent.contexts?.trace).toEqual({ - trace_id: transactionEvent.contexts?.trace?.trace_id, - span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-5/tests/spans.test.ts new file mode 100644 index 000000000000..d9ee28ee6689 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-5/tests/spans.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-5', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/`); + + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v5', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + page.on('console', msg => console.log(msg.text())); + const pageloadSpanPromise = waitForStreamedSpan('react-router-5', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-5', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v5', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts deleted file mode 100644 index 7e053e5c9fb2..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-5/tests/transactions.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-5', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/`); - - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v5', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - page.on('console', msg => console.log(msg.text())); - const pageloadTxnPromise = waitForTransaction('react-router-5', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-5', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - await pageloadTxnPromise; - - const linkElement = page.locator('id=navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v5', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }, - }, - }, - transaction: '/user/:id', - transaction_info: { - source: 'route', - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx index aca20a716203..58fadb60f61c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx @@ -16,7 +16,6 @@ import Index from './pages/Index'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/spans.test.ts new file mode 100644 index 000000000000..2a50331fa451 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/spans.test.ts @@ -0,0 +1,294 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/projects/123/views/234/567`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(span.name).toBe('/projects/:projectId/views/:viewId/:detailId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/views/234/567', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/234\/567$/), + type: 'string', + }, + }); +}); + +test('sends a pageload span with a parameterized URL - alternative route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/projects/234/old-views/234/567`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(span.name).toBe('/projects/:projectId/old-views/:viewId/:detailId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/old-views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/234/old-views/234/567', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/234\/old-views\/234\/567$/), + type: 'string', + }, + }); +}); + +test('keeps the parent path prefix for a descendant route with non-wildcard nested children - pageload', async ({ + page, +}) => { + const spanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/child/abc123`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Child')).toBe(true); + expect(span.name).toBe('/child/:id'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/child/:id', type: 'string' }, + 'url.path': { value: '/child/abc123', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(navigationSpan.name).toBe('/projects/:projectId/views/:viewId/:detailId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/views/456/789', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/456\/789$/), + type: 'string', + }, + }); +}); + +test('sends a navigation span with a parameterized URL - alternative route', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=old-navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Details')).toBe(true); + expect(navigationSpan.name).toBe('/projects/:projectId/old-views/:viewId/:detailId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/projects/:projectId/old-views/:viewId/:detailId', type: 'string' }, + 'url.path': { value: '/projects/123/old-views/345/654', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/old-views\/345\/654$/), + type: 'string', + }, + }); +}); + +test('keeps the parent path prefix for a descendant route with non-wildcard nested children - navigation', async ({ + page, +}) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + const linkElement = page.locator('id=child-navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Child')).toBe(true); + expect(navigationSpan.name).toBe('/child/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/child/:id', type: 'string' }, + 'url.path': { value: '/child/abc123', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), type: 'string' }, + }); +}); + +test('resolves deep wildcard chain with three levels of nesting - pageload', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/workspace/team/u123`); + + const span = await spanPromise; + + expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); + expect(span.name).toBe('/workspace/:teamId/:memberId'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/workspace/:teamId/:memberId', type: 'string' }, + 'url.path': { value: '/workspace/team/u123', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), type: 'string' }, + }); +}); + +test('does not mix param names across independent descendant routers', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const fooNavigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.attributes['url.path']?.value === '/foo/123'; + }); + + const barNavigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.attributes['url.path']?.value === '/bar/456'; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + // Mount the first descendant router (`foo/*` -> `:fooId`), which populates the shared `allRoutes` set. + const [, fooNavigationSpan] = await Promise.all([ + page.locator('id=foo-navigation').click(), + fooNavigationSpanPromise, + ]); + + expect((await page.innerHTML('#root')).includes('Foo')).toBe(true); + expect(fooNavigationSpan.name).toBe('/foo/:fooId'); + expect(fooNavigationSpan.attributes['sentry.segment.name.source']?.value).toBe('route'); + + // Return to the index so we can navigate into the second, unrelated descendant router client-side. + // A fresh page load would reset the module-level `allRoutes` and hide the bug. + await page.goBack(); + await page.locator('id=bar-navigation').waitFor(); + + // Now mount the second descendant router (`bar/*` -> `:barId`). With the accumulation bug, the name + // comes out as the hybrid `/bar/:fooId`. + const [, barNavigationSpan] = await Promise.all([ + page.locator('id=bar-navigation').click(), + barNavigationSpanPromise, + ]); + + expect((await page.innerHTML('#root')).includes('Bar')).toBe(true); + expect(barNavigationSpan.name).toBe('/bar/:barId'); + expect(barNavigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/bar/:barId', type: 'string' }, + 'url.path': { value: '/bar/456', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/bar\/456$/), type: 'string' }, + }); +}); + +test('resolves deep wildcard chain with three levels of nesting - navigation', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-descendant-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=deep-member-navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); + expect(navigationSpan.name).toBe('/workspace/:teamId/:memberId'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/workspace/:teamId/:memberId', type: 'string' }, + 'url.path': { value: '/workspace/team/u123', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts deleted file mode 100644 index 61ee41d33301..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/projects/123/views/234/567`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/views/:viewId/:detailId', - 'url.path': '/projects/123/views/234/567', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/234\/567$/), - }, - }, - }, - transaction: '/projects/:projectId/views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a pageload transaction with a parameterized URL - alternative route', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/projects/234/old-views/234/567`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/old-views/:viewId/:detailId', - 'url.path': '/projects/234/old-views/234/567', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/234\/old-views\/234\/567$/), - }, - }, - }, - transaction: '/projects/:projectId/old-views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('keeps the parent path prefix for a descendant route with non-wildcard nested children - pageload', async ({ - page, -}) => { - const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/child/abc123`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Child')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/child/:id', - 'url.path': '/child/abc123', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), - }, - }, - }, - transaction: '/child/:id', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/views/:viewId/:detailId', - 'url.path': '/projects/123/views/456/789', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/views\/456\/789$/), - }, - }, - }, - transaction: '/projects/:projectId/views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL - alternative route', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=old-navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Details')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/projects/:projectId/old-views/:viewId/:detailId', - 'url.path': '/projects/123/old-views/345/654', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/projects\/123\/old-views\/345\/654$/), - }, - }, - }, - transaction: '/projects/:projectId/old-views/:viewId/:detailId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('keeps the parent path prefix for a descendant route with non-wildcard nested children - navigation', async ({ - page, -}) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - const pageloadTxn = await pageloadTxnPromise; - - expect(pageloadTxn).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); - - const linkElement = page.locator('id=child-navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Child')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/child/:id', - 'url.path': '/child/abc123', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/child\/abc123$/), - }, - }, - }, - transaction: '/child/:id', - transaction_info: { - source: 'route', - }, - }); -}); - -test('resolves deep wildcard chain with three levels of nesting - pageload', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/workspace/team/u123`); - - const rootSpan = await transactionPromise; - - expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/workspace/:teamId/:memberId', - 'url.path': '/workspace/team/u123', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), - }, - }, - }, - transaction: '/workspace/:teamId/:memberId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('does not mix param names across independent descendant routers', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const fooNavigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.contexts?.trace?.data?.['url.path'] === '/foo/123' - ); - }); - - const barNavigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'navigation' && - transactionEvent.contexts?.trace?.data?.['url.path'] === '/bar/456' - ); - }); - - await page.goto(`/`); - await pageloadTxnPromise; - - // Mount the first descendant router (`foo/*` -> `:fooId`), which populates the shared `allRoutes` set. - const [, fooNavigationTxn] = await Promise.all([page.locator('id=foo-navigation').click(), fooNavigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Foo')).toBe(true); - expect(fooNavigationTxn).toMatchObject({ - transaction: '/foo/:fooId', - transaction_info: { source: 'route' }, - }); - - // Return to the index so we can navigate into the second, unrelated descendant router client-side. - // A fresh page load would reset the module-level `allRoutes` and hide the bug. - await page.goBack(); - await page.locator('id=bar-navigation').waitFor(); - - // Now mount the second descendant router (`bar/*` -> `:barId`). With the accumulation bug, the name - // comes out as the hybrid `/bar/:fooId`. - const [, barNavigationTxn] = await Promise.all([page.locator('id=bar-navigation').click(), barNavigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Bar')).toBe(true); - expect(barNavigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/bar/:barId', - 'url.path': '/bar/456', - }, - }, - }, - transaction: '/bar/:barId', - transaction_info: { - source: 'route', - }, - }); -}); - -test('resolves deep wildcard chain with three levels of nesting - navigation', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - await pageloadTxnPromise; - - const linkElement = page.locator('id=deep-member-navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect((await page.innerHTML('#root')).includes('Deep Member')).toBe(true); - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/workspace/:teamId/:memberId', - 'url.path': '/workspace/team/u123', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/workspace\/team\/u123$/), - }, - }, - }, - transaction: '/workspace/:teamId/:memberId', - transaction_info: { - source: 'route', - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx index a049e56eee74..0ac33b9f6c5f 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/src/index.tsx @@ -15,7 +15,6 @@ import User from './pages/User'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/errors.test.ts index 272025d95877..707a6af5df7a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends correct error event', async ({ page }) => { const errorEventPromise = waitForError('react-router-6-use-routes', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-use-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-use-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-6-use-routes', event => { @@ -39,9 +39,9 @@ test('Sets correct transactionName', async ({ page }) => { }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - // Only capture error once transaction was sent + // Only capture error once the pageload span was sent const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); @@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => { expect(errorEvent.transaction).toEqual('/'); expect(errorEvent.contexts?.trace).toEqual({ - trace_id: transactionEvent.contexts?.trace?.trace_id, - span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/spans.test.ts new file mode 100644 index 000000000000..8fe66736d026 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/spans.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-use-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/`); + + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized URL', async ({ page }) => { + page.on('console', msg => console.log(msg.text())); + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-use-routes', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-use-routes', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts deleted file mode 100644 index 4c5e65b25e4e..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/transactions.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6-use-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto(`/`); - - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, - }); -}); - -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { - page.on('console', msg => console.log(msg.text())); - const pageloadTxnPromise = waitForTransaction('react-router-6-use-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-6-use-routes', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - await pageloadTxnPromise; - - const linkElement = page.locator('id=navigation'); - - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }, - }, - }, - transaction: '/user/:id', - transaction_info: { - source: 'route', - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx index b569aa9aedd1..58dc4614f650 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx @@ -18,7 +18,6 @@ import User from './pages/User'; const replay = Sentry.replayIntegration(); Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.REACT_APP_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/errors.test.ts index e34699420ccf..cbb90ddac056 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/errors.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends correct error event', async ({ page }) => { const errorEventPromise = waitForError('react-router-6', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-6', event => { @@ -39,9 +39,9 @@ test('Sets correct transactionName', async ({ page }) => { }); await page.goto('/'); - const transactionEvent = await transactionPromise; + const pageloadSpan = await pageloadSpanPromise; - // Only capture error once transaction was sent + // Only capture error once the pageload span was sent const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); @@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => { expect(errorEvent.transaction).toEqual('/'); expect(errorEvent.contexts?.trace).toEqual({ - trace_id: transactionEvent.contexts?.trace?.trace_id, - span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/navigation-trace-propagation.test.ts index 477b27fd40f5..53a6cbc2fd6d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/navigation-trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/navigation-trace-propagation.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({ page, @@ -15,22 +15,22 @@ test('propagates the navigation trace (not the stale pageload trace) for a fetch }); }); - const pageloadTxnPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-6', async transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/products'; + const navigationSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/products'; }); await page.goto('/'); - const pageloadTxn = await pageloadTxnPromise; + const pageloadSpan = await pageloadSpanPromise; await page.locator('id=navigation-products').click(); - const navigationTxn = await navigationTxnPromise; + const navigationSpan = await navigationSpanPromise; - const pageloadTraceId = pageloadTxn.contexts?.trace?.trace_id; - const navigationTraceId = navigationTxn.contexts?.trace?.trace_id; + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; const propagatedTraceId = productsRequestSentryTrace?.split('-')[0]; expect(pageloadTraceId).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts similarity index 57% rename from dev-packages/e2e-tests/test-applications/react-router-6/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts index 22f79766c400..d4303fc9ab72 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/spans.test.ts @@ -1,69 +1,51 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; -test('sends a pageload transaction with a parameterized URL', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; +test('sends a pageload span with a parameterized URL', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); await page.goto(`/`); - const rootSpan = await transactionPromise; - - expect(rootSpan).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }, - }, - }, - transaction: '/', - transaction_info: { - source: 'route', - }, + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, }); }); -test('sends a navigation transaction with a parameterized URL', async ({ page }) => { +test('sends a navigation span with a parameterized URL', async ({ page }) => { page.on('console', msg => console.log(msg.text())); - const pageloadTxnPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + const navigationSpanPromise = waitForStreamedSpan('react-router-6', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; }); await page.goto(`/`); - await pageloadTxnPromise; + await pageloadSpanPromise; const linkElement = page.locator('id=navigation'); - const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.react.reactrouter_v6', - data: { - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }, - }, - }, - transaction: '/user/:id', - transaction_info: { - source: 'route', - }, + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts index a73642b20b6f..6b859c9e789c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts @@ -1,107 +1,121 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +// Under span streaming the http.client name is only ` `, so the request URL has to +// come from the `url.full` attribute. +function findHttpClientSpan(spans: SerializedStreamedSpan[], op: string, urlFull: string): SerializedStreamedSpan { + return spans.find(span => getSpanOp(span) === op && span.attributes['url.full']?.value === urlFull)!; +} + +function durationInSeconds(span: SerializedStreamedSpan): number { + return Math.round(span.end_timestamp - span.start_timestamp); +} + +/** + * Every test here loads `/sse` and produces identically named spans, and streamed spans are buffered + * before they flush, so an earlier test's page load can still be arriving. A pageload span starts at + * the browser's time origin, so requiring it to start after this test navigated is what picks out + * this test's own trace. + */ +function isPageloadSpanAfter(navigatedAt: number) { + return (span: SerializedStreamedSpan) => + getSpanOp(span) === 'pageload' && span.is_segment && span.start_timestamp >= navigatedAt; +} test('Waits for sse streaming when creating spans', async ({ page }) => { + const navigatedAt = Date.now() / 1000; await page.goto('/sse'); - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); + const spansPromise = collectStreamedSpans( + 'react-router-6', + spansOfTrace => + spansOfTrace.some(isPageloadSpanAfter(navigatedAt)) && + spansOfTrace.some(span => getSpanOp(span) === 'http.client.stream'), + ); const fetchButton = page.locator('id=fetch-button'); await fetchButton.click(); - const rootSpan = await transactionPromise; - const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0]!; - const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse')[0]!; - const httpStream = rootSpan.spans?.filter(span => span.op === 'http.client.stream')[0]!; + const spans = await spansPromise; + + const sseFetchCall = spans.find(span => span.name === 'sse fetch call')!; + const httpGet = findHttpClientSpan(spans, 'http.client', 'http://localhost:8080/sse'); + const httpStream = findHttpClientSpan(spans, 'http.client.stream', 'http://localhost:8080/sse'); expect(sseFetchCall).toBeDefined(); expect(httpGet).toBeDefined(); expect(httpStream).toBeDefined(); // http headers get sent instantly from the server - const resolveDuration = Math.round((sseFetchCall.timestamp as number) - sseFetchCall.start_timestamp); - - // http.client span ends at header arrival (~0s) - const httpGetDuration = Math.round((httpGet.timestamp as number) - httpGet.start_timestamp); - - // body streaming duration is captured in the sibling http.client.stream span (~2s) - const streamDuration = Math.round((httpStream.timestamp as number) - httpStream.start_timestamp); - - expect(resolveDuration).toBe(0); - expect(httpGetDuration).toBe(0); - expect(streamDuration).toBe(2); + // http.client span ends at header arrival (~0s), body streaming duration is captured in the + // sibling http.client.stream span (~2s) + expect(durationInSeconds(sseFetchCall)).toBe(0); + expect(durationInSeconds(httpGet)).toBe(0); + expect(durationInSeconds(httpStream)).toBe(2); }); test('Waits for sse streaming when sse has been explicitly aborted', async ({ page }) => { + const navigatedAt = Date.now() / 1000; await page.goto('/sse'); - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); + const consoleMessages: string[] = []; + page.on('console', msg => consoleMessages.push(msg.text())); + + const spansPromise = collectStreamedSpans( + 'react-router-6', + spansOfTrace => + spansOfTrace.some(isPageloadSpanAfter(navigatedAt)) && spansOfTrace.some(span => span.name === 'sse fetch call'), + ); const fetchButton = page.locator('id=fetch-sse-abort'); await fetchButton.click(); - const rootSpan = await transactionPromise; - const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0]!; - const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse')[0]!; + const spans = await spansPromise; + + const sseFetchCall = spans.find(span => span.name === 'sse fetch call')!; + const httpGet = findHttpClientSpan(spans, 'http.client', 'http://localhost:8080/sse'); expect(sseFetchCall).toBeDefined(); expect(httpGet).toBeDefined(); - expect(sseFetchCall?.timestamp).toBeDefined(); - expect(sseFetchCall?.start_timestamp).toBeDefined(); - expect(httpGet?.timestamp).toBeDefined(); - expect(httpGet?.start_timestamp).toBeDefined(); - - // http headers get sent instantly from the server - const resolveDuration = Math.round((sseFetchCall.timestamp as number) - sseFetchCall.start_timestamp); - - // body streams after 0s because it has been aborted - const resolveBodyDuration = Math.round((httpGet.timestamp as number) - httpGet.start_timestamp); - - expect(resolveDuration).toBe(0); - expect(resolveBodyDuration).toBe(0); - - // validate abort error was thrown by inspecting console - expect(rootSpan.breadcrumbs).toContainEqual( - expect.objectContaining({ - category: 'console', - message: 'Could not fetch sse AbortError: BodyStreamBuffer was aborted', - }), - ); + // http headers get sent instantly from the server, and the body streams after 0s because it has + // been aborted + expect(durationInSeconds(sseFetchCall)).toBe(0); + expect(durationInSeconds(httpGet)).toBe(0); + + // Spans carry no breadcrumbs, so the abort error is validated on the console directly + expect( + consoleMessages.some( + message => message.includes('Could not fetch sse') && message.includes('BodyStreamBuffer was aborted'), + ), + ).toBe(true); }); test('Aborts when stream takes longer than 5s, by not updating the span duration', async ({ page }) => { + const navigatedAt = Date.now() / 1000; await page.goto('/sse'); - const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); + const spansPromise = collectStreamedSpans( + 'react-router-6', + spansOfTrace => + spansOfTrace.some(isPageloadSpanAfter(navigatedAt)) && + spansOfTrace.some(span => span.attributes['url.full']?.value === 'http://localhost:8080/sse-timeout'), + ); const fetchButton = page.locator('id=fetch-timeout-button'); await fetchButton.click(); - const rootSpan = await transactionPromise; - const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0]!; - const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse-timeout')[0]!; + const spans = await spansPromise; + + const sseFetchCall = spans.find(span => span.name === 'sse fetch call')!; + const httpGet = findHttpClientSpan(spans, 'http.client', 'http://localhost:8080/sse-timeout'); expect(sseFetchCall).toBeDefined(); expect(httpGet).toBeDefined(); - expect(sseFetchCall?.timestamp).toBeDefined(); - expect(sseFetchCall?.start_timestamp).toBeDefined(); - expect(httpGet?.timestamp).toBeDefined(); - expect(httpGet?.start_timestamp).toBeDefined(); - - // http headers get sent instantly from the server - const resolveDuration = Math.round((sseFetchCall.timestamp as number) - sseFetchCall.start_timestamp); - - // body streams after 10s but client should abort reading after 5s - const resolveBodyDuration = Math.round((httpGet.timestamp as number) - httpGet.start_timestamp); - - expect(resolveDuration).toBe(0); - expect(resolveBodyDuration).toBe(0); + // http headers get sent instantly from the server, and the body streams after 10s but the client + // aborts reading after 5s + expect(durationInSeconds(sseFetchCall)).toBe(0); + expect(durationInSeconds(httpGet)).toBe(0); });