From 436f96955f7a732531b520c65f9546aaf743a489 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 11:29:00 +0200 Subject: [PATCH 1/4] 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/4] 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); }); From a3cd640681458415c9147b14a3eda9ced414d8fd Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 2 Sep 2026 11:29:22 +0200 Subject: [PATCH 3/4] test(e2e): Port React Router data router E2E apps to span streaming Removes the `traceLifecycle: 'static'` pin from `react-create-browser-router`, `react-create-hash-router` and `react-create-memory-router`, and rewrites their specs against streamed span v2. Two attribute groups moved under streaming and the assertions follow them: the navigator data is now `device.memory.estimated_capacity`, `device.processor_count` and `network.connection.effective_type`, and the pageload timings are `browser.performance.*`. LCP is streamed as its own `ui.webvital.lcp` span once the page hides, so it is no longer asserted on the pageload span. --- .../react-create-browser-router/src/index.tsx | 1 - .../tests/spans.test.ts | 167 ++++++ .../tests/transactions.test.ts | 218 -------- .../react-create-hash-router/src/index.tsx | 1 - .../tests/spans.test.ts | 344 ++++++++++++ .../tests/transactions.test.ts | 502 ------------------ .../react-create-memory-router/src/index.tsx | 1 - .../tests/spans.test.ts | 70 +++ .../tests/transactions.test.ts | 93 ---- 9 files changed, 581 insertions(+), 816 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx index bec2f5b24481..c7ad16eebcf7 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-browser-router/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/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-create-browser-router/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/spans.test.ts new file mode 100644 index 000000000000..5736ae47040f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/spans.test.ts @@ -0,0 +1,167 @@ +import { expect, test } from '@playwright/test'; +import { + collectStreamedSpans, + getSpanOp, + waitForStreamedSpan, + waitForStreamedSpans, +} from '@sentry-internal/test-utils'; + +const PREVIOUS_TRACE_LINK = [ + { + attributes: { + 'sentry.link.type': { value: 'previous_trace', type: 'string' }, + }, + sampled: true, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, +]; + +test('Captures a pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-browser-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/'); + + const span = await spanPromise; + + expect(span.name).toBe('/'); + expect(span.status).toBe('ok'); + expect(span.span_id).toMatch(/[a-f0-9]{16}/); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + + expect(span.attributes).toMatchObject({ + 'device.memory.estimated_capacity': { value: expect.any(Number), type: expect.any(String) }, + 'device.processor_count': { value: expect.any(Number), type: 'integer' }, + 'network.connection.effective_type': { value: expect.any(String), type: 'string' }, + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + '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('Captures a navigation span', async ({ page }) => { + const spansPromise = waitForStreamedSpans('react-create-browser-router', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment); + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const spans = await spansPromise; + const navigationSpan = spans.find(span => span.is_segment)!; + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.status).toBe('ok'); + expect(navigationSpan.span_id).toMatch(/[a-f0-9]{16}/); + expect(navigationSpan.trace_id).toMatch(/[a-f0-9]{32}/); + + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + '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' }, + }); + + expect(navigationSpan.links).toEqual(PREVIOUS_TRACE_LINK); + + // Filter out favicon spans which may or may not be present depending on the browser version + const childSpans = spans.filter( + span => !span.is_segment && !(span.attributes['url.full']?.value as string | undefined)?.includes('favicon'), + ); + expect(childSpans).toEqual([]); +}); + +test('Captures a lazy pageload span', async ({ page }) => { + const spansPromise = collectStreamedSpans('react-create-browser-router', spans => { + return spans.some(span => getSpanOp(span) === 'pageload' && span.is_segment); + }); + + await page.goto('/lazy-loaded-user/5/foo'); + + const spans = await spansPromise; + const pageloadSpan = spans.find(span => getSpanOp(span) === 'pageload' && span.is_segment)!; + + expect(pageloadSpan.name).toBe('/lazy-loaded-user/:id/:innerId'); + expect(pageloadSpan.status).toBe('ok'); + + expect(pageloadSpan.attributes).toMatchObject({ + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/lazy-loaded-user/:id/:innerId', type: 'string' }, + 'url.path': { value: '/lazy-loaded-user/5/foo', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/lazy-loaded-user\/5\/foo$/), + type: 'string', + }, + }); + + expect(await page.innerText('id=content')).toContain('I am a lazy loaded user'); + + // One span for the outer lazy route, one for the inner one + const resourceSpans = spans.filter( + span => + span.trace_id === pageloadSpan.trace_id && + getSpanOp(span) === 'resource.script' && + span.attributes['sentry.origin']?.value === 'auto.resource.browser.metrics', + ); + expect(resourceSpans.length).toBeGreaterThanOrEqual(2); +}); + +test('Captures a lazy navigation span', async ({ page }) => { + const spansPromise = collectStreamedSpans('react-create-browser-router', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment); + }); + + await page.goto('/'); + const linkElement = page.locator('id=lazy-navigation'); + await linkElement.click(); + + const spans = await spansPromise; + const navigationSpan = spans.find(span => getSpanOp(span) === 'navigation' && span.is_segment)!; + + expect(navigationSpan.name).toBe('/lazy-loaded-user/:id/:innerId'); + expect(navigationSpan.status).toBe('ok'); + + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/lazy-loaded-user/:id/:innerId', type: 'string' }, + 'url.path': { value: '/lazy-loaded-user/5/foo', type: 'string' }, + 'url.full': { + value: expect.stringMatching(/^https?:\/\/localhost:\d+\/lazy-loaded-user\/5\/foo$/), + type: 'string', + }, + }); + + expect(navigationSpan.links).toEqual(PREVIOUS_TRACE_LINK); + + expect(await page.innerText('id=content')).toContain('I am a lazy loaded user'); + + // The pageload of `/` is its own trace, so the lazy-route chunks are scoped to the navigation + // trace. One span for the outer lazy route, one for the inner one. + const resourceSpans = spans.filter( + span => + span.trace_id === navigationSpan.trace_id && + getSpanOp(span) === 'resource.script' && + span.attributes['sentry.origin']?.value === 'auto.resource.browser.metrics', + ); + expect(resourceSpans.length).toBeGreaterThanOrEqual(2); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts deleted file mode 100644 index 79737d920682..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-create-browser-router/tests/transactions.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Captures a pageload transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-browser-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - expect(transactionEvent.contexts?.trace).toEqual( - expect.objectContaining({ - data: expect.objectContaining({ - deviceMemory: expect.any(String), - effectiveConnectionType: expect.any(String), - hardwareConcurrency: expect.any(String), - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }), - op: 'pageload', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.pageload.react.reactrouter_v6', - status: 'ok', - }), - ); -}); - -test('Captures a navigation transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-browser-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: expect.objectContaining({ - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/user/5', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), - }), - links: [ - { - attributes: { - 'sentry.link.type': 'previous_trace', - }, - sampled: true, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - ], - op: 'navigation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.navigation.react.reactrouter_v6', - status: 'ok', - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/user/:id', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - // Filter out favicon spans which may or may not be present depending on the browser version - const spans = (transactionEvent.spans || []).filter(span => !span.description?.includes('favicon')); - expect(spans).toEqual([]); -}); - -test('Captures a lazy pageload transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-browser-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/lazy-loaded-user/5/foo'); - - const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: expect.objectContaining({ - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/lazy-loaded-user/:id/:innerId', - 'url.path': '/lazy-loaded-user/5/foo', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/lazy-loaded-user\/5\/foo$/), - }), - op: 'pageload', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.pageload.react.reactrouter_v6', - status: 'ok', - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/lazy-loaded-user/:id/:innerId', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - expect(await page.innerText('id=content')).toContain('I am a lazy loaded user'); - - expect(transactionEvent.spans).toEqual( - expect.arrayContaining([ - // This one is the outer lazy route - expect.objectContaining({ - op: 'resource.script', - origin: 'auto.resource.browser.metrics', - }), - // This one is the inner lazy route - expect.objectContaining({ - op: 'resource.script', - origin: 'auto.resource.browser.metrics', - }), - ]), - ); -}); - -test('Captures a lazy navigation transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-browser-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=lazy-navigation'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: expect.objectContaining({ - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/lazy-loaded-user/:id/:innerId', - 'url.path': '/lazy-loaded-user/5/foo', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/lazy-loaded-user\/5\/foo$/), - }), - links: [ - { - attributes: { - 'sentry.link.type': 'previous_trace', - }, - sampled: true, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - ], - op: 'navigation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.navigation.react.reactrouter_v6', - status: 'ok', - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/lazy-loaded-user/:id/:innerId', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - expect(await page.innerText('id=content')).toContain('I am a lazy loaded user'); - - expect(transactionEvent.spans).toEqual( - expect.arrayContaining([ - // This one is the outer lazy route - expect.objectContaining({ - op: 'resource.script', - origin: 'auto.resource.browser.metrics', - }), - // This one is the inner lazy route - expect.objectContaining({ - op: 'resource.script', - origin: 'auto.resource.browser.metrics', - }), - ]), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx index 277600d3b7c6..86de5f20378d 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx @@ -16,7 +16,6 @@ import Group from './pages/Group'; 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-create-hash-router/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/spans.test.ts new file mode 100644 index 000000000000..d42b08d85a5a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/spans.test.ts @@ -0,0 +1,344 @@ +import { expect, test } from '@playwright/test'; +import { + collectStreamedSpans, + getSpanOp, + waitForStreamedSpan, + waitForStreamedSpans, +} from '@sentry-internal/test-utils'; + +const BROWSER_TIMING_OPS = [ + 'browser.dom_content_loaded_event', + 'browser.connect', + 'browser.request', + 'browser.response', +]; + +test('Captures a pageload span', async ({ page }) => { + const spansPromise = collectStreamedSpans('react-create-hash-router', spans => { + return spans.some(span => getSpanOp(span) === 'pageload' && span.is_segment); + }); + + await page.goto('/'); + + const spans = await spansPromise; + const pageloadSpan = spans.find(span => getSpanOp(span) === 'pageload' && span.is_segment)!; + + expect(pageloadSpan.name).toBe('/'); + expect(pageloadSpan.status).toBe('ok'); + expect(pageloadSpan.span_id).toMatch(/[a-f0-9]{16}/); + expect(pageloadSpan.trace_id).toMatch(/[a-f0-9]{32}/); + + // Matched partially: every streamed span also carries the common `sentry.sdk.*`, `sentry.release`, + // `sentry.environment` and segment attributes, and a pageload span additionally carries the web + // vitals. LCP is streamed as its own `ui.webvital.lcp` span once the page is hidden, so it is no + // longer part of the pageload span's attributes. + expect(pageloadSpan.attributes).toMatchObject({ + 'device.memory.estimated_capacity': { value: expect.any(Number), type: expect.any(String) }, + 'device.processor_count': { value: expect.any(Number), type: 'integer' }, + 'network.connection.effective_type': { value: expect.any(String), type: 'string' }, + 'browser.performance.time_origin': { value: expect.any(Number), type: expect.any(String) }, + 'browser.performance.navigation.activation_start': { value: expect.any(Number), type: expect.any(String) }, + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + }); + + for (const op of BROWSER_TIMING_OPS) { + expect(spans).toContainEqual( + expect.objectContaining({ + name: page.url(), + is_segment: false, + status: 'ok', + parent_span_id: pageloadSpan.span_id, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: pageloadSpan.trace_id, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.ui.browser.metrics', type: 'string' }, + 'sentry.op': { value: op, type: 'string' }, + }), + }), + ); + } +}); + +test('Captures a navigation span', async ({ page }) => { + const spansPromise = waitForStreamedSpans('react-create-hash-router', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment); + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const spans = await spansPromise; + const navigationSpan = spans.find(span => span.is_segment)!; + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.status).toBe('ok'); + + expect(navigationSpan.attributes).toMatchObject({ + 'device.memory.estimated_capacity': { value: expect.any(Number), type: expect.any(String) }, + 'device.processor_count': { value: expect.any(Number), type: 'integer' }, + 'network.connection.effective_type': { value: expect.any(String), type: 'string' }, + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^http:\/\/localhost:3030\/#\/user\/5$/), type: 'string' }, + }); + + expect(navigationSpan.links).toEqual([ + { + attributes: { + 'sentry.link.type': { value: 'previous_trace', type: 'string' }, + }, + sampled: true, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ]); + + // Filter out favicon spans which may or may not be present depending on the browser version + const childSpans = spans.filter( + span => !span.is_segment && !(span.attributes['url.full']?.value as string | undefined)?.includes('favicon'), + ); + expect(childSpans).toEqual([]); +}); + +test('Captures a parameterized path pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/#/v2/post/1'); + + const span = await spanPromise; + + expect(span.name).toBe('/v2/post/:post'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v2/post/:post', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v2/post/1', type: 'string' }, + }); +}); + +test('Captures a parameterized path pageload span for nested route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/#/v2/post/1/featured'); + + const span = await spanPromise; + + expect(span.name).toBe('/v2/post/:post/featured'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v2/post/:post/featured', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v2/post/1/featured', type: 'string' }, + }); +}); + +test('Captures a parameterized path pageload span for deeply nested route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/#/v1/post/1/edit'); + + const span = await spanPromise; + + expect(span.name).toBe('/v1/post/:post/edit'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v1/post/:post/edit', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v1/post/1/edit', type: 'string' }, + }); +}); + +test('Captures a parameterized path pageload span for nested route with absolute path', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/#/v2/post/1/related'); + + const span = await spanPromise; + + expect(span.name).toBe('/v2/post/:post/related'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v2/post/:post/related', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v2/post/1/related', type: 'string' }, + }); +}); + +test('Captures a parameterized path navigation span', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1'); + await linkElement.click(); + + const span = await spanPromise; + + expect(span.name).toBe('/v2/post/:post'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v2/post/:post', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v2/post/1', type: 'string' }, + }); +}); + +test('Captures a parameterized path navigation span for nested route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-featured'); + await linkElement.click(); + + const span = await spanPromise; + + expect(span.name).toBe('/v2/post/:post/featured'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v2/post/:post/featured', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v2/post/1/featured', type: 'string' }, + }); +}); + +test('Captures a parameterized path navigation span for deeply nested route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-edit'); + await linkElement.click(); + + const span = await spanPromise; + + expect(span.name).toBe('/v1/post/:post/edit'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v1/post/:post/edit', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v1/post/1/edit', type: 'string' }, + }); +}); + +test('Captures a parameterized path navigation span for nested route with absolute path', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-related'); + await linkElement.click(); + + const span = await spanPromise; + + expect(span.name).toBe('/v2/post/:post/related'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/v2/post/:post/related', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/v2/post/1/related', type: 'string' }, + }); +}); + +test('Captures a parameterized path pageload span for group route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/#/group/1'); + + const span = await spanPromise; + + expect(span.name).toBe('/group/:group/:user?'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/group/:group/:user?', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/group/1', type: 'string' }, + }); +}); + +test('Captures a parameterized path navigation span for group route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-group-1'); + await linkElement.click(); + + const span = await spanPromise; + + expect(span.name).toBe('/group/:group/:user?'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/group/:group/:user?', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/group/1', type: 'string' }, + }); +}); + +test('Captures a parameterized path pageload span for nested group route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/#/group/1/5'); + + const span = await spanPromise; + + expect(span.name).toBe('/group/:group/:user?'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/group/:group/:user?', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/group/1/5', type: 'string' }, + }); +}); + +test('Captures a parameterized path navigation span for nested group route', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-hash-router', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-group-1-user-5'); + await linkElement.click(); + + const span = await spanPromise; + + expect(span.name).toBe('/group/:group/:user?'); + expect(span.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/group/:group/:user?', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: 'http://localhost:3030/#/group/1/5', type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts deleted file mode 100644 index 5479f1935c83..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Captures a pageload transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/'); - - const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: { - deviceMemory: expect.any(String), - effectiveConnectionType: expect.any(String), - hardwareConcurrency: expect.any(String), - 'lcp.element': expect.any(String), - 'lcp.id': expect.any(String), - 'lcp.size': expect.any(Number), - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'performance.timeOrigin': expect.any(Number), - 'performance.activationStart': expect.any(Number), - 'lcp.renderTime': expect.any(Number), - 'lcp.loadTime': expect.any(Number), - 'url.template': '/', - 'url.full': 'http://localhost:3030/', - 'url.path': '/', - }, - op: 'pageload', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.pageload.react.reactrouter_v6', - status: 'ok', - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - expect(transactionEvent.spans).toContainEqual({ - data: { - 'sentry.origin': 'auto.ui.browser.metrics', - 'sentry.op': 'browser.dom_content_loaded_event', - }, - description: page.url(), - op: 'browser.dom_content_loaded_event', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.ui.browser.metrics', - status: 'ok', - }); - expect(transactionEvent.spans).toContainEqual({ - data: { - 'sentry.origin': 'auto.ui.browser.metrics', - 'sentry.op': 'browser.connect', - }, - description: page.url(), - op: 'browser.connect', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.ui.browser.metrics', - status: 'ok', - }); - expect(transactionEvent.spans).toContainEqual({ - data: { - 'sentry.origin': 'auto.ui.browser.metrics', - 'sentry.op': 'browser.request', - }, - description: page.url(), - op: 'browser.request', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.ui.browser.metrics', - status: 'ok', - }); - expect(transactionEvent.spans).toContainEqual({ - data: { - 'sentry.origin': 'auto.ui.browser.metrics', - 'sentry.op': 'browser.response', - }, - description: page.url(), - op: 'browser.response', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.ui.browser.metrics', - status: 'ok', - }); -}); - -test('Captures a navigation transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: expect.objectContaining({ - deviceMemory: expect.any(String), - effectiveConnectionType: expect.any(String), - hardwareConcurrency: expect.any(String), - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/', - 'url.full': expect.stringMatching(/^http:\/\/localhost:3030\/#\/user\/5$/), - }), - links: [ - { - attributes: { - 'sentry.link.type': 'previous_trace', - }, - sampled: true, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - ], - op: 'navigation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.navigation.react.reactrouter_v6', - status: 'ok', - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/user/:id', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - // Filter out favicon spans which may or may not be present depending on the browser version - const spans = (transactionEvent.spans || []).filter(span => !span.description?.includes('favicon')); - expect(spans).toEqual([]); -}); - -test('Captures a parameterized path pageload transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/#/v2/post/1'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v2/post/:post', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v2/post/:post', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v2/post/1', - }, - }, - }, - }); -}); - -test('Captures a parameterized path pageload transaction for nested route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/#/v2/post/1/featured'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v2/post/:post/featured', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v2/post/:post/featured', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v2/post/1/featured', - }, - }, - }, - }); -}); - -test('Captures a parameterized path pageload transaction for deeply nested route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/#/v1/post/1/edit'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v1/post/:post/edit', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v1/post/:post/edit', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v1/post/1/edit', - }, - }, - }, - }); -}); - -test('Captures a parameterized path pageload transaction for nested route with absolute path', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/#/v2/post/1/related'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v2/post/:post/related', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v2/post/:post/related', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v2/post/1/related', - }, - }, - }, - }); -}); - -test('Captures a parameterized path navigation transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-post-1'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v2/post/:post', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v2/post/:post', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v2/post/1', - }, - }, - }, - }); -}); - -test('Captures a parameterized path navigation transaction for nested route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-post-1-featured'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v2/post/:post/featured', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v2/post/:post/featured', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v2/post/1/featured', - }, - }, - }, - }); -}); - -test('Captures a parameterized path navigation transaction for deeply nested route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-post-1-edit'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v1/post/:post/edit', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v1/post/:post/edit', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v1/post/1/edit', - }, - }, - }, - }); -}); - -test('Captures a parameterized path navigation transaction for nested route with absolute path', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-post-1-related'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/v2/post/:post/related', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/v2/post/:post/related', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/v2/post/1/related', - }, - }, - }, - }); -}); - -test('Captures a parameterized path pageload transaction for group route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/#/group/1'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/group/:group/:user?', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/group/:group/:user?', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/group/1', - }, - }, - }, - }); -}); - -test('Captures a parameterized path navigation transaction for group route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-group-1'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/group/:group/:user?', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/group/:group/:user?', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/group/1', - }, - }, - }, - }); -}); - -test('Captures a parameterized path pageload transaction for nested group route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/#/group/1/5'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/group/:group/:user?', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/group/:group/:user?', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/group/1/5', - }, - }, - }, - }); -}); - -test('Captures a parameterized path navigation transaction for nested group route', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-group-1-user-5'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toMatchObject({ - transaction: '/group/:group/:user?', - type: 'transaction', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - data: { - 'url.template': '/group/:group/:user?', - 'url.path': '/', - 'url.full': 'http://localhost:3030/#/group/1/5', - }, - }, - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/index.tsx index 8cd283650abd..f71572f9dc1f 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-memory-router/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/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-create-memory-router/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/spans.test.ts new file mode 100644 index 000000000000..16aef9df1e4e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/spans.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; + +test('Captures a pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-create-memory-router', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/'); + + const span = await spanPromise; + + expect(span.name).toBe('/user/:id'); + expect(span.status).toBe('ok'); + expect(span.span_id).toMatch(/[a-f0-9]{16}/); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + + expect(span.attributes).toMatchObject({ + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); +}); + +test('Captures a navigation span', async ({ page }) => { + const spansPromise = waitForStreamedSpans('react-create-memory-router', spans => { + return spans.some(span => getSpanOp(span) === 'navigation' && span.is_segment); + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-button'); + await linkElement.click(); + + const spans = await spansPromise; + const span = spans.find(span => span.is_segment)!; + + expect(span.name).toBe('/user/:id'); + expect(span.status).toBe('ok'); + expect(span.span_id).toMatch(/[a-f0-9]{16}/); + expect(span.trace_id).toMatch(/[a-f0-9]{32}/); + + expect(span.attributes).toMatchObject({ + 'sentry.idle_span_finish_reason': { value: 'idleTimeout', type: 'string' }, + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' }, + 'sentry.sample_rate': { value: 1, type: 'integer' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' }, + }); + + expect(span.links).toEqual([ + { + attributes: { + 'sentry.link.type': { value: 'previous_trace', type: 'string' }, + }, + sampled: true, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ]); + + expect(spans.filter(span => !span.is_segment)).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts deleted file mode 100644 index 32e2d36cae8c..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-create-memory-router/tests/transactions.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Captures a pageload transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-memory-router', event => { - return event.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/'); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/user/:id', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - expect(transactionEvent.contexts?.trace).toEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }), - op: 'pageload', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.pageload.react.reactrouter_v6', - status: 'ok', - }), - ); -}); - -test('Captures a navigation transaction', async ({ page }) => { - const transactionEventPromise = waitForTransaction('react-create-memory-router', event => { - return event.contexts?.trace?.op === 'navigation'; - }); - - await page.goto('/'); - const linkElement = page.locator('id=navigation-button'); - await linkElement.click(); - - const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace).toEqual({ - data: expect.objectContaining({ - 'sentry.idle_span_finish_reason': 'idleTimeout', - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.react.reactrouter_v6', - 'sentry.sample_rate': 1, - 'sentry.segment.name.source': 'route', - 'url.template': '/user/:id', - 'url.path': '/', - 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), - }), - links: [ - { - attributes: { - 'sentry.link.type': 'previous_trace', - }, - sampled: true, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - ], - op: 'navigation', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.navigation.react.reactrouter_v6', - status: 'ok', - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: '/user/:id', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - expect(transactionEvent.spans).toEqual([]); -}); From 5723654f6d0f94538ccb2f21a5cf3f3498dc10ff Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:20:39 +0200 Subject: [PATCH 4/4] test(e2e): Port React Router SPA E2E apps to span streaming (#23843) ## What Ports `react-router-7-spa`, `react-router-8-spa`, `react-router-7-cross-usage`, `react-router-8-cross-usage` and `react-router-8-cloudflare` to span streaming. ## Why Span streaming is the default now, so the E2E suite has to exercise it. The Cloudflare mysql spec matches on `db.query.text`, because a streamed mysql span is named after its query summary and both queries in that route come out as `SELECT`. Part of #23798 --- .../react-router-7-cross-usage/src/index.tsx | 1 - .../tests/spans.test.ts | 132 +++++++++++++ .../tests/transactions.test.ts | 174 ------------------ .../react-router-7-spa/src/main.tsx | 1 - .../react-router-7-spa/tests/errors.test.ts | 14 +- .../navigation-trace-propagation.test.ts | 18 +- .../{transactions.test.ts => spans.test.ts} | 78 +++----- .../app/entry.client.tsx | 1 - .../tests/db.test.ts | 61 +++--- .../react-router-8-cloudflare/workers/app.ts | 1 - .../react-router-8-cross-usage/src/index.tsx | 1 - .../tests/spans.test.ts | 132 +++++++++++++ .../tests/transactions.test.ts | 174 ------------------ .../react-router-8-spa/src/main.tsx | 1 - .../react-router-8-spa/tests/errors.test.ts | 14 +- .../navigation-trace-propagation.test.ts | 18 +- .../{transactions.test.ts => spans.test.ts} | 78 +++----- 17 files changed, 390 insertions(+), 509 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts rename dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/{transactions.test.ts => spans.test.ts} (57%) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/transactions.test.ts rename dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/{transactions.test.ts => spans.test.ts} (57%) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx index fe7c390d792a..6dd70edf6622 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx @@ -18,7 +18,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-7-cross-usage/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/spans.test.ts new file mode 100644 index 000000000000..139ee1bb2c08 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/spans.test.ts @@ -0,0 +1,132 @@ +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-7-cross-usage', 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', 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-7-cross-usage', 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', 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('sends a navigation span with a parameterized URL', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-cross-usage', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-7-cross-usage', 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', 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', 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-7-cross-usage', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-7-cross-usage', 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', 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', 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', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts deleted file mode 100644 index b9365417ce69..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/tests/transactions.test.ts +++ /dev/null @@ -1,174 +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-7-cross-usage', 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', - 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-7-cross-usage', 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', - 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('sends a navigation transaction with a parameterized URL', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-7-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-7-cross-usage', 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', - 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', - 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-7-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-7-cross-usage', 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', - 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', - 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', - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx index eb0b44122b62..87131036ce76 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.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: import.meta.env.PUBLIC_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts index e31d3c4066d4..7736abdbad64 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/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, baseURL }) => { const errorEventPromise = waitForError('react-router-7-spa', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page, baseURL }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-7-spa', 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-7-spa/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/navigation-trace-propagation.test.ts index e9867237903a..da621a5729e9 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/navigation-trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/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-7-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/products'; + const navigationSpanPromise = waitForStreamedSpan('react-router-7-spa', 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-7-spa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts similarity index 57% rename from dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/spans.test.ts index 81dc4d25d274..ed4a0832515e 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/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-7-spa', 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-7-spa', 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', - 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', 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-7-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + const navigationSpanPromise = waitForStreamedSpan('react-router-7-spa', 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', - 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', 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-8-cloudflare/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx index 8fc9f5510160..0d928d68760a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/app/entry.client.tsx @@ -4,7 +4,6 @@ import { hydrateRoot } from 'react-dom/client'; import { HydratedRouter } from 'react-router/dom'; Sentry.init({ - traceLifecycle: 'static', environment: 'qa', // dynamic sampling bias to keep transactions dsn: 'https://username@domain/123', tunnel: `http://localhost:3031/`, // proxy server diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts index 345ae42d0629..ebef990b9bfc 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/tests/db.test.ts @@ -1,43 +1,50 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +// Under span streaming the mysql span name is the query summary, so both queries here are named +// `SELECT`. `db.query.text` is what tells them apart. test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ request }) => { - const transactionPromise = waitForTransaction('react-router-8-cloudflare', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - (transactionEvent.spans?.some(span => span.op === 'db') ?? false) - ); - }); + const spansPromise = collectStreamedSpans( + 'react-router-8-cloudflare', + spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'http.server' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'db'), + ); const res = await request.get('/performance/db-mysql'); expect(res.status()).toBe(200); - const transactionEvent = await transactionPromise; - const dbSpans = transactionEvent.spans!.filter(span => span.op === 'db'); + const spans = await spansPromise; + const dbSpans = spans.filter(span => getSpanOp(span) === 'db'); - const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution'); + const firstQuery = dbSpans.find(span => span.attributes['db.query.text']?.value === 'SELECT 1 + 1 AS solution'); expect(firstQuery).toBeDefined(); - expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.mysql'); - expect(firstQuery!.data?.['db.system.name']).toBe('mysql'); - expect(firstQuery!.data?.['db.query.text']).toBe('SELECT 1 + 1 AS solution'); - expect(firstQuery!.data?.['server.address']).toBe('127.0.0.1'); - expect(firstQuery!.data?.['server.port']).toBe(3306); - expect(firstQuery!.data?.['db.user']).toBe('root'); + expect(firstQuery!.name).toBe('SELECT'); + expect(firstQuery!.attributes['sentry.origin']?.value).toBe('auto.db.mysql'); + expect(firstQuery!.attributes['db.system.name']?.value).toBe('mysql'); + expect(firstQuery!.attributes['server.address']?.value).toBe('127.0.0.1'); + expect(firstQuery!.attributes['server.port']?.value).toBe(3306); + expect(firstQuery!.attributes['db.user']?.value).toBe('root'); }); -test('a nested query lands on the same transaction (async context restored)', async ({ request }) => { - const transactionPromise = waitForTransaction('react-router-8-cloudflare', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - (transactionEvent.spans?.filter(span => span.op === 'db').length ?? 0) >= 2 - ); - }); +test('a nested query lands on the same segment (async context restored)', async ({ request }) => { + const spansPromise = collectStreamedSpans( + 'react-router-8-cloudflare', + spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'http.server' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'db').length >= 2, + ); const res = await request.get('/performance/db-mysql'); expect(res.status()).toBe(200); - const transactionEvent = await transactionPromise; - const descriptions = transactionEvent.spans!.filter(span => span.op === 'db').map(span => span.description); - expect(descriptions).toContain('SELECT 1 + 1 AS solution'); - expect(descriptions).toContain('SELECT NOW()'); + const spans = await spansPromise; + + // These are scoped to the http.server segment's trace, so both queries landing here is what proves + // the nested one kept the async context. + const queryTexts = spans + .filter(span => getSpanOp(span) === 'db') + .map(span => span.attributes['db.query.text']?.value); + expect(queryTexts).toContain('SELECT 1 + 1 AS solution'); + expect(queryTexts).toContain('SELECT NOW()'); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts index d80e3741947c..d9a6c5451d72 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cloudflare/workers/app.ts @@ -11,7 +11,6 @@ interface Env { // `diagnostics_channel` publishers would fire with nobody subscribed. export default Sentry.withSentry( (env: Env) => ({ - traceLifecycle: 'static', dsn: env.E2E_TEST_DSN, tunnel: 'http://localhost:3031/', tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/index.tsx index dd76f1229e98..8bc0a6023c55 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/src/index.tsx @@ -18,7 +18,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-8-cross-usage/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/spans.test.ts new file mode 100644 index 000000000000..04a164b1736f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/spans.test.ts @@ -0,0 +1,132 @@ +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-8-cross-usage', 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', 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-8-cross-usage', 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', 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('sends a navigation span with a parameterized URL', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-cross-usage', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-8-cross-usage', 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', 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', 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-8-cross-usage', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-8-cross-usage', 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', 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', 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', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/transactions.test.ts deleted file mode 100644 index 1865b8ca16c5..000000000000 --- a/dev-packages/e2e-tests/test-applications/react-router-8-cross-usage/tests/transactions.test.ts +++ /dev/null @@ -1,174 +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-8-cross-usage', 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', - 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-8-cross-usage', 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', - 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('sends a navigation transaction with a parameterized URL', async ({ page }) => { - const pageloadTxnPromise = waitForTransaction('react-router-8-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-8-cross-usage', 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', - 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', - 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-8-cross-usage', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const navigationTxnPromise = waitForTransaction('react-router-8-cross-usage', 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', - 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', - 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', - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/main.tsx index eb0b44122b62..87131036ce76 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/src/main.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: import.meta.env.PUBLIC_E2E_TEST_DSN, integrations: [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/errors.test.ts index 97a147f065e0..32b1f1e0a5fb 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/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, baseURL }) => { const errorEventPromise = waitForError('react-router-8-spa', event => { @@ -30,8 +30,8 @@ test('Sends correct error event', async ({ page, baseURL }) => { }); test('Sets correct transactionName', async ({ page }) => { - const transactionPromise = waitForTransaction('react-router-8-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); const errorEventPromise = waitForError('react-router-8-spa', 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-8-spa/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/navigation-trace-propagation.test.ts index 921c20dee117..a20c67db4438 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/navigation-trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/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-8-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-8-spa', async transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/products'; + const navigationSpanPromise = waitForStreamedSpan('react-router-8-spa', 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-8-spa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts similarity index 57% rename from dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/spans.test.ts index d4f229597f6e..bf401e9f9ac2 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-spa/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-spa/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-8-spa', 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-8-spa', 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', - 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', 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-8-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-spa', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; }); - const navigationTxnPromise = waitForTransaction('react-router-8-spa', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + const navigationSpanPromise = waitForStreamedSpan('react-router-8-spa', 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', - 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', 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' }, }); });