diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/.gitignore b/dev-packages/e2e-tests/test-applications/react-17-static/.gitignore
new file mode 100644
index 000000000000..84634c973eeb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-17-static/.gitignore
@@ -0,0 +1,29 @@
+# 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
diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/package.json b/dev-packages/e2e-tests/test-applications/react-17-static/package.json
new file mode 100644
index 000000000000..71df50a6bc4e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-17-static/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "react-17-static",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@sentry/react": "file:../../packed/sentry-react-packed.tgz",
+ "@types/react": "17.0.2",
+ "@types/react-dom": "17.0.2",
+ "react": "17.0.2",
+ "react-dom": "17.0.2",
+ "react-router-dom": "~6.3.0",
+ "react-scripts": "5.0.1",
+ "typescript": "~5.0.0"
+ },
+ "scripts": {
+ "build": "react-scripts build",
+ "dev": "react-scripts start",
+ "start": "serve -s build",
+ "test": "playwright test",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && pnpm build",
+ "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && pnpm build",
+ "test:assert": "pnpm 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"
+ ]
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.56.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "serve": "14.0.1"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-17-static/playwright.config.mjs
new file mode 100644
index 000000000000..31f2b913b58b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-17-static/playwright.config.mjs
@@ -0,0 +1,7 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm start`,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/public/index.html b/dev-packages/e2e-tests/test-applications/react-17-static/public/index.html
new file mode 100644
index 000000000000..68f33b623e38
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-17-static/public/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+ React App
+
+
+
+
+
+
+
diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-17-static/src/index.tsx
new file mode 100644
index 000000000000..c303ff60fe80
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-17-static/src/index.tsx
@@ -0,0 +1,59 @@
+import * as Sentry from '@sentry/react';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import {
+ BrowserRouter,
+ Route,
+ Routes,
+ createRoutesFromChildren,
+ matchRoutes,
+ useLocation,
+ useNavigationType,
+} from 'react-router-dom';
+import Index from './pages/Index';
+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: [
+ Sentry.reactRouterV6BrowserTracingIntegration({
+ useEffect: React.useEffect,
+ useLocation,
+ useNavigationType,
+ createRoutesFromChildren,
+ matchRoutes,
+ }),
+ replay,
+ ],
+ // We recommend adjusting this value in production, or using tracesSampler
+ // for finer control
+ tracesSampleRate: 1.0,
+ release: 'e2e-test',
+
+ // Always capture replays, so we can test this properly
+ replaysSessionSampleRate: 1.0,
+ replaysOnErrorSampleRate: 0.0,
+
+ tunnel: 'http://localhost:3031',
+});
+
+const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
+
+function App() {
+ return (
+
+
+
+ } />
+ } />
+
+
+
+ );
+}
+
+ReactDOM.render(, document.getElementById('root'));
diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-17-static/src/pages/Index.tsx
new file mode 100644
index 000000000000..754af82320e9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-17-static/src/pages/Index.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react';
+import { Link } from 'react-router-dom';
+
+const Index = () => {
+ return (
+ <>
+ {
+ throw new Error('I am an error!');
+ }}
+ />
+
+ navigate to user
+
+ >
+ );
+};
+
+export default Index;
diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-17-static/src/pages/User.tsx
new file mode 100644
index 000000000000..671455a92fff
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-17-static/src/pages/User.tsx
@@ -0,0 +1,7 @@
+import * as React from 'react';
+
+const User = () => {
+ return I am a blank page :)
;
+};
+
+export default User;
diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/src/react-app-env.d.ts b/dev-packages/e2e-tests/test-applications/react-17-static/src/react-app-env.d.ts
new file mode 100644
index 000000000000..6431bc5fc6b2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-17-static/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-17-static/start-event-proxy.mjs
new file mode 100644
index 000000000000..77b36ea71c6f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-17-static/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'react-17-static',
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-17-static/tests/errors.test.ts
new file mode 100644
index 000000000000..dded4dfe2dbb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-17-static/tests/errors.test.ts
@@ -0,0 +1,59 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Sends correct error event', async ({ page }) => {
+ const errorEventPromise = waitForError('react-17-static', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
+ });
+
+ await page.goto('/');
+
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ const errorEvent = await errorEventPromise;
+
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!');
+
+ expect(errorEvent.request).toEqual({
+ headers: expect.any(Object),
+ url: 'http://localhost:3030/',
+ });
+
+ expect(errorEvent.transaction).toEqual('/');
+
+ expect(errorEvent.contexts?.trace).toEqual({
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ });
+});
+
+test('Sets correct transactionName', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-17-static', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const errorEventPromise = waitForError('react-17-static', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
+ });
+
+ await page.goto('/');
+ const transactionEvent = await transactionPromise;
+
+ // Only capture error once transaction was sent
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ const errorEvent = await errorEventPromise;
+
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!');
+
+ 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 || ''),
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts
new file mode 100644
index 000000000000..e78693bdfd2c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-17-static/tests/transactions.test.ts
@@ -0,0 +1,126 @@
+import { expect, test } from '@playwright/test';
+import { getSpanOp, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils';
+
+test('sends a pageload transaction with a parameterized URL', async ({ page }) => {
+ const transactionPromise = waitForTransaction('react-17-static', 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_v6',
+ 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-17-static', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const navigationTxnPromise = waitForTransaction('react-17-static', 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_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',
+ },
+ });
+});
+
+test('sends an INP span', async ({ page }) => {
+ const inpSpanPromise = waitForStreamedSpan('react-17-static', span => {
+ return getSpanOp(span) === 'ui.interaction.click';
+ });
+
+ await page.goto(`/`);
+
+ await page.click('#exception-button');
+
+ await page.waitForTimeout(500);
+
+ // Page hide to trigger INP
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+
+ const inpSpan = await inpSpanPromise;
+
+ const inpValue = inpSpan.attributes['browser.web_vital.inp.value']?.value as number;
+ expect(inpValue).toBeGreaterThan(0);
+
+ const pageloadSpanId = inpSpan.parent_span_id;
+
+ expect(inpSpan).toEqual(
+ expect.objectContaining({
+ name: 'body > div#root > input#exception-button[type="button"]',
+ span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ trace_id: expect.stringMatching(/^[\da-f]{32}$/),
+ parent_span_id: expect.stringMatching(/^[\da-f]{16}$/),
+ start_timestamp: expect.any(Number),
+ end_timestamp: expect.any(Number),
+ is_segment: false,
+ status: 'ok',
+ }),
+ );
+ expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp);
+
+ // `client.address` and replay/user attributes are added by the server or vary by run, so we assert
+ // the stable subset rather than the exhaustive attribute set.
+ expect(inpSpan.attributes).toEqual(
+ expect.objectContaining({
+ 'sentry.op': { value: 'ui.interaction.click', type: 'string' },
+ 'sentry.origin': { value: 'auto.http.browser.inp', type: 'string' },
+ 'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
+ 'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
+ 'sentry.transaction': { value: '/', type: 'string' },
+ 'sentry.segment.name': { value: '/', type: 'string' },
+ 'sentry.segment.id': { value: pageloadSpanId, type: 'string' },
+ 'sentry.pageload.span_id': { value: pageloadSpanId, type: 'string' },
+ 'sentry.trace_lifecycle': { value: 'stream', type: 'string' },
+ 'sentry.release': { value: 'e2e-test', type: 'string' },
+ 'sentry.environment': { value: 'qa', type: 'string' },
+ 'user_agent.original': { value: expect.stringContaining('Chrome'), type: 'string' },
+ }),
+ );
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-17-static/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-17-static/tsconfig.json
new file mode 100644
index 000000000000..76ffed0e7ed2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-17-static/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "es2018",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src", "tests"]
+}