Skip to content

Commit 738bf4e

Browse files
authored
test(e2e): Port React E2E test apps to span streaming (#23849)
## What Ports `create-react-app`, `lighthouse-react`, `react-17`, `react-19` and `react-send-to-sentry` to span streaming. ## Why Span streaming is the default now, so the E2E suite has to exercise it. `react-send-to-sentry` needed more than a helper swap: pageload and navigation segments never become transaction events under streaming, so the app records them from `beforeSendSpan` and the trace lookup matches a span id rather than a transaction event id. That lookup runs against real Sentry, so CI is what confirms the shape. `react-19` keeps its specs unchanged, since they only cover error handling and the profiler. Closes: #23799
1 parent d4057c6 commit 738bf4e

10 files changed

Lines changed: 107 additions & 108 deletions

File tree

dev-packages/e2e-tests/test-applications/create-react-app/src/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import App from './App';
55
import './index.css';
66

77
Sentry.init({
8-
traceLifecycle: 'static',
98
environment: 'qa', // dynamic sampling bias to keep transactions
109
dsn: 'https://public@dsn.ingest.sentry.io/1337',
1110
integrations: [Sentry.browserTracingIntegration()],

dev-packages/e2e-tests/test-applications/lighthouse-react/src/main.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ performance.mark('sentry-sdk-init-start', {
1616

1717
if (import.meta.env.MODE === 'tracing-replay') {
1818
Sentry.init({
19-
traceLifecycle: 'static',
2019
dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
2120
release: 'lighthouse-fixture',
2221
environment: 'qa',
@@ -28,7 +27,6 @@ if (import.meta.env.MODE === 'tracing-replay') {
2827
} else if (import.meta.env.MODE === 'tracing') {
2928
// Tracing + errors, but no replay — isolates the replay integration's cost.
3029
Sentry.init({
31-
traceLifecycle: 'static',
3230
dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
3331
release: 'lighthouse-fixture',
3432
environment: 'qa',
@@ -40,7 +38,6 @@ if (import.meta.env.MODE === 'tracing-replay') {
4038
// (We don't recommend this setup anywhere and neither will it work well.
4139
// this is purely for testing if it changes anything about overhead.)
4240
Sentry.init({
43-
traceLifecycle: 'static',
4441
dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
4542
release: 'lighthouse-fixture',
4643
environment: 'qa',
@@ -53,15 +50,13 @@ if (import.meta.env.MODE === 'tracing-replay') {
5350
} else if (import.meta.env.MODE === 'errors-only') {
5451
// Default integrations only — errors are always captured, no tracing or replay.
5552
Sentry.init({
56-
traceLifecycle: 'static',
5753
dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
5854
release: 'lighthouse-fixture',
5955
environment: 'qa',
6056
});
6157
} else if (import.meta.env.MODE === 'minimal-integrations') {
6258
// Minimal integratoins setup only (everything necessary to automatically get errors)
6359
Sentry.init({
64-
traceLifecycle: 'static',
6560
dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
6661
release: 'lighthouse-fixture',
6762
environment: 'qa',
@@ -79,7 +74,6 @@ if (import.meta.env.MODE === 'tracing-replay') {
7974
// DSN set but every integration disabled. Isolates the cost of the enabled
8075
// client itself from the default instrumentation that wraps DOM/timer/network APIs.
8176
Sentry.init({
82-
traceLifecycle: 'static',
8377
dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
8478
release: 'lighthouse-fixture',
8579
environment: 'qa',
@@ -91,7 +85,6 @@ if (import.meta.env.MODE === 'tracing-replay') {
9185
// removeEventListener on ~32 prototypes plus setTimeout/setInterval/rAF/XHR.
9286
// Isolates that global monkey-patching cost from the rest of the defaults.
9387
Sentry.init({
94-
traceLifecycle: 'static',
9588
dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
9689
release: 'lighthouse-fixture',
9790
environment: 'qa',
@@ -102,7 +95,6 @@ if (import.meta.env.MODE === 'tracing-replay') {
10295
// Default integrations minus Breadcrumbs, which adds a lot of monkey patching to
10396
// DOM and Network APIs as well as event targets and listeners
10497
Sentry.init({
105-
traceLifecycle: 'static',
10698
dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
10799
release: 'lighthouse-fixture',
108100
environment: 'qa',
@@ -112,7 +104,6 @@ if (import.meta.env.MODE === 'tracing-replay') {
112104
// Default integrations minus Breadcrumbs, which adds a lot of monkey patching to
113105
// DOM and Network APIs as well as event targets and listeners
114106
Sentry.init({
115-
traceLifecycle: 'static',
116107
dsn: import.meta.env.VITE_E2E_TEST_DSN as string | undefined,
117108
release: 'lighthouse-fixture',
118109
environment: 'qa',
@@ -122,7 +113,7 @@ if (import.meta.env.MODE === 'tracing-replay') {
122113
} else if (import.meta.env.MODE === 'init-only') {
123114
// enabled: false makes the SDK a guaranteed no-op (no transport allocation,
124115
// no DSN warning). We're measuring pure SDK-loading + tree-shaking cost.
125-
Sentry.init({ traceLifecycle: 'static', enabled: false });
116+
Sentry.init({ enabled: false });
126117
}
127118

128119
performance.measure('sentry-sdk-init-duration', {

dev-packages/e2e-tests/test-applications/react-17/src/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import User from './pages/User';
1616
const replay = Sentry.replayIntegration();
1717

1818
Sentry.init({
19-
traceLifecycle: 'static',
2019
environment: 'qa', // dynamic sampling bias to keep transactions
2120
dsn: process.env.REACT_APP_E2E_TEST_DSN,
2221
integrations: [

dev-packages/e2e-tests/test-applications/react-17/tests/errors.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
2+
import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

44
test('Sends correct error event', async ({ page }) => {
55
const errorEventPromise = waitForError('react-17', event => {
@@ -30,18 +30,18 @@ test('Sends correct error event', async ({ page }) => {
3030
});
3131

3232
test('Sets correct transactionName', async ({ page }) => {
33-
const transactionPromise = waitForTransaction('react-17', async transactionEvent => {
34-
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
33+
const pageloadSpanPromise = waitForStreamedSpan('react-17', span => {
34+
return getSpanOp(span) === 'pageload' && span.is_segment;
3535
});
3636

3737
const errorEventPromise = waitForError('react-17', event => {
3838
return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
3939
});
4040

4141
await page.goto('/');
42-
const transactionEvent = await transactionPromise;
42+
const pageloadSpan = await pageloadSpanPromise;
4343

44-
// Only capture error once transaction was sent
44+
// Only capture error once the pageload span was sent
4545
const exceptionButton = page.locator('id=exception-button');
4646
await exceptionButton.click();
4747

@@ -53,7 +53,7 @@ test('Sets correct transactionName', async ({ page }) => {
5353
expect(errorEvent.transaction).toEqual('/');
5454

5555
expect(errorEvent.contexts?.trace).toEqual({
56-
trace_id: transactionEvent.contexts?.trace?.trace_id,
57-
span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''),
56+
trace_id: pageloadSpan.trace_id,
57+
span_id: expect.not.stringContaining(pageloadSpan.span_id),
5858
});
5959
});

dev-packages/e2e-tests/test-applications/react-17/tests/transactions.test.ts renamed to dev-packages/e2e-tests/test-applications/react-17/tests/spans.test.ts

Lines changed: 30 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,51 @@
11
import { expect, test } from '@playwright/test';
2-
import { getSpanOp, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils';
2+
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

4-
test('sends a pageload transaction with a parameterized URL', async ({ page }) => {
5-
const transactionPromise = waitForTransaction('react-17', async transactionEvent => {
6-
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
4+
test('sends a pageload span with a parameterized URL', async ({ page }) => {
5+
const spanPromise = waitForStreamedSpan('react-17', span => {
6+
return getSpanOp(span) === 'pageload' && span.is_segment;
77
});
88

99
await page.goto(`/`);
1010

11-
const rootSpan = await transactionPromise;
12-
13-
expect(rootSpan).toMatchObject({
14-
contexts: {
15-
trace: {
16-
op: 'pageload',
17-
origin: 'auto.pageload.react.reactrouter_v6',
18-
data: {
19-
'sentry.segment.name.source': 'route',
20-
'url.template': '/',
21-
'url.path': '/',
22-
'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/$/),
23-
},
24-
},
25-
},
26-
transaction: '/',
27-
transaction_info: {
28-
source: 'route',
29-
},
11+
const span = await spanPromise;
12+
13+
expect(span.name).toBe('/');
14+
expect(span.attributes).toMatchObject({
15+
'sentry.op': { value: 'pageload', type: 'string' },
16+
'sentry.origin': { value: 'auto.pageload.react.reactrouter_v6', type: 'string' },
17+
'sentry.segment.name.source': { value: 'route', type: 'string' },
18+
'url.template': { value: '/', type: 'string' },
19+
'url.path': { value: '/', type: 'string' },
20+
'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/$/), type: 'string' },
3021
});
3122
});
3223

33-
test('sends a navigation transaction with a parameterized URL', async ({ page }) => {
24+
test('sends a navigation span with a parameterized URL', async ({ page }) => {
3425
page.on('console', msg => console.log(msg.text()));
35-
const pageloadTxnPromise = waitForTransaction('react-17', async transactionEvent => {
36-
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
26+
const pageloadSpanPromise = waitForStreamedSpan('react-17', span => {
27+
return getSpanOp(span) === 'pageload' && span.is_segment;
3728
});
3829

39-
const navigationTxnPromise = waitForTransaction('react-17', async transactionEvent => {
40-
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
30+
const navigationSpanPromise = waitForStreamedSpan('react-17', span => {
31+
return getSpanOp(span) === 'navigation' && span.is_segment;
4132
});
4233

4334
await page.goto(`/`);
44-
await pageloadTxnPromise;
35+
await pageloadSpanPromise;
4536

4637
const linkElement = page.locator('id=navigation');
4738

48-
const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]);
49-
50-
expect(navigationTxn).toMatchObject({
51-
contexts: {
52-
trace: {
53-
op: 'navigation',
54-
origin: 'auto.navigation.react.reactrouter_v6',
55-
data: {
56-
'sentry.segment.name.source': 'route',
57-
'url.template': '/user/:id',
58-
'url.path': '/user/5',
59-
'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/),
60-
},
61-
},
62-
},
63-
transaction: '/user/:id',
64-
transaction_info: {
65-
source: 'route',
66-
},
39+
const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]);
40+
41+
expect(navigationSpan.name).toBe('/user/:id');
42+
expect(navigationSpan.attributes).toMatchObject({
43+
'sentry.op': { value: 'navigation', type: 'string' },
44+
'sentry.origin': { value: 'auto.navigation.react.reactrouter_v6', type: 'string' },
45+
'sentry.segment.name.source': { value: 'route', type: 'string' },
46+
'url.template': { value: '/user/:id', type: 'string' },
47+
'url.path': { value: '/user/5', type: 'string' },
48+
'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' },
6749
});
6850
});
6951

dev-packages/e2e-tests/test-applications/react-19/src/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import ReactDOM from 'react-dom/client';
44
import Index from './pages/Index';
55

66
Sentry.init({
7-
traceLifecycle: 'static',
87
environment: 'qa', // dynamic sampling bias to keep transactions
98
dsn: process.env.REACT_APP_E2E_TEST_DSN,
109
release: 'e2e-test',

dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/globals.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,15 @@ interface RecordedEvent {
44
op?: string;
55
}
66

7+
/** A streamed segment span, identified by its span id rather than an event id. */
8+
interface RecordedSpan {
9+
spanId: string;
10+
traceId: string;
11+
op?: string;
12+
}
13+
714
interface Window {
8-
recordedTransactions?: RecordedEvent[];
15+
recordedSegmentSpans?: RecordedSpan[];
916
capturedException?: RecordedEvent;
1017
sentryReplayId?: string;
1118
}

dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/index.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import User from './pages/User';
1616
const replay = Sentry.replayIntegration();
1717

1818
Sentry.init({
19-
traceLifecycle: 'static',
2019
environment: 'qa', // dynamic sampling bias to keep transactions
2120
dsn: process.env.REACT_APP_E2E_TEST_DSN,
2221
integrations: [
@@ -37,6 +36,19 @@ Sentry.init({
3736
// Always capture replays, so we can test this properly
3837
replaysSessionSampleRate: 1.0,
3938
replaysOnErrorSampleRate: 0.0,
39+
40+
// Streamed spans never become transaction events, so the pageload and navigation segments are
41+
// recorded here instead of in the event processor below. They are looked up by span id.
42+
beforeSendSpan(span) {
43+
const op = span.attributes['sentry.op'];
44+
45+
if (span.is_segment && typeof op === 'string' && (op === 'pageload' || op === 'navigation')) {
46+
window.recordedSegmentSpans = window.recordedSegmentSpans || [];
47+
window.recordedSegmentSpans.push({ spanId: span.span_id, traceId: span.trace_id, op });
48+
}
49+
50+
return span;
51+
},
4052
});
4153

4254
Object.defineProperty(window, 'sentryReplayId', {
@@ -50,16 +62,8 @@ Object.defineProperty(window, 'sentryReplayId', {
5062
Sentry.addEventProcessor(event => {
5163
const eventId = event.event_id;
5264
const traceId = event.contexts?.trace?.trace_id;
53-
const op = event.contexts?.trace?.op;
54-
55-
if (!eventId || !traceId) {
56-
return event;
57-
}
5865

59-
if (event.type === 'transaction' && (op === 'pageload' || op === 'navigation')) {
60-
window.recordedTransactions = window.recordedTransactions || [];
61-
window.recordedTransactions.push({ eventId, traceId, op });
62-
} else if (!event.type && event.exception) {
66+
if (eventId && traceId && !event.type && event.exception) {
6367
window.capturedException = { eventId, traceId };
6468
}
6569

dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/send-to-sentry.test.ts

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, test } from '@playwright/test';
22
import { ReplayRecordingData } from './fixtures/ReplayRecordingData';
3-
import { EVENT_POLLING_OPTIONS, findErrorInTrace, findTransactionInTrace } from './utils/sentry-api';
3+
import { EVENT_POLLING_OPTIONS, findErrorInTrace, findSpanInTrace } from './utils/sentry-api';
44

55
const EVENT_POLLING_TIMEOUT = 90_000;
66

@@ -28,52 +28,48 @@ test('Sends an exception to Sentry', async ({ page }) => {
2828
await expect.poll(() => findErrorInTrace(traceId, eventId), EVENT_POLLING_OPTIONS).toBeDefined();
2929
});
3030

31-
test('Sends a pageload transaction to Sentry', async ({ page }) => {
31+
test('Sends a pageload span to Sentry', async ({ page }) => {
3232
await page.goto('/');
3333

34-
const transactionHandle = await page.waitForFunction(() =>
35-
window.recordedTransactions?.find(transaction => transaction.op === 'pageload'),
34+
const spanHandle = await page.waitForFunction(() =>
35+
window.recordedSegmentSpans?.find(span => span.op === 'pageload'),
3636
);
37-
const pageloadTransaction = await transactionHandle.jsonValue();
37+
const pageloadSpan = await spanHandle.jsonValue();
3838

39-
if (pageloadTransaction === undefined) {
40-
throw new Error("Application didn't record a pageload transaction.");
39+
if (pageloadSpan === undefined) {
40+
throw new Error("Application didn't record a pageload span.");
4141
}
4242

43-
const { eventId, traceId } = pageloadTransaction;
43+
const { spanId, traceId } = pageloadSpan;
4444

45-
console.log(`Polling for pageload transaction eventId: ${eventId} in trace: ${traceId}`);
45+
console.log(`Polling for pageload spanId: ${spanId} in trace: ${traceId}`);
4646

47-
await expect
48-
.poll(() => findTransactionInTrace(traceId, eventId), EVENT_POLLING_OPTIONS)
49-
.toMatchObject({ op: 'pageload' });
47+
await expect.poll(() => findSpanInTrace(traceId, 'pageload'), EVENT_POLLING_OPTIONS).toBeDefined();
5048
});
5149

52-
test('Sends a navigation transaction to Sentry', async ({ page }) => {
50+
test('Sends a navigation span to Sentry', async ({ page }) => {
5351
await page.goto('/');
5452

55-
// Give pageload transaction time to finish
53+
// Give the pageload span time to finish
5654
await page.waitForTimeout(4000);
5755

5856
const linkElement = page.locator('id=navigation');
5957
await linkElement.click();
6058

61-
const transactionHandle = await page.waitForFunction(() =>
62-
window.recordedTransactions?.find(transaction => transaction.op === 'navigation'),
59+
const spanHandle = await page.waitForFunction(() =>
60+
window.recordedSegmentSpans?.find(span => span.op === 'navigation'),
6361
);
64-
const navigationTransaction = await transactionHandle.jsonValue();
62+
const navigationSpan = await spanHandle.jsonValue();
6563

66-
if (navigationTransaction === undefined) {
67-
throw new Error("Application didn't record a navigation transaction.");
64+
if (navigationSpan === undefined) {
65+
throw new Error("Application didn't record a navigation span.");
6866
}
6967

70-
const { eventId, traceId } = navigationTransaction;
68+
const { spanId, traceId } = navigationSpan;
7169

72-
console.log(`Polling for navigation transaction eventId: ${eventId} in trace: ${traceId}`);
70+
console.log(`Polling for navigation spanId: ${spanId} in trace: ${traceId}`);
7371

74-
await expect
75-
.poll(() => findTransactionInTrace(traceId, eventId), EVENT_POLLING_OPTIONS)
76-
.toMatchObject({ op: 'navigation' });
72+
await expect.poll(() => findSpanInTrace(traceId, 'navigation'), EVENT_POLLING_OPTIONS).toBeDefined();
7773
});
7874

7975
test('Sends a Replay recording to Sentry', async ({ browser }) => {

0 commit comments

Comments
 (0)