Skip to content

Commit be21d07

Browse files
committed
test(e2e): Port the React Router instrumentation API E2E app to span streaming (#23845)
## What Ports `react-router-7-framework-instrumentation` to span streaming. ## Why Span streaming is the default now, so the E2E suite has to exercise it. Loader, action, middleware and fetcher assertions walk the streamed trace instead of a transaction's `spans` array, and the orchestrion mysql spec matches on `db.query.text`, since a streamed mysql span is named after its query summary. Part of #23798
1 parent 161ad31 commit be21d07

10 files changed

Lines changed: 475 additions & 648 deletions

File tree

dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { HydratedRouter } from 'react-router/dom';
88
const tracing = Sentry.reactRouterTracingIntegration({ useInstrumentationAPI: true });
99

1010
Sentry.init({
11-
traceLifecycle: 'static',
1211
environment: 'qa', // dynamic sampling bias to keep transactions
1312
dsn: 'https://username@domain/123',
1413
tunnel: `http://localhost:3031/`, // proxy server

dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as Sentry from '@sentry/react-router';
22

33
Sentry.init({
4-
traceLifecycle: 'static',
54
dsn: 'https://username@domain/123',
65
environment: 'qa', // dynamic sampling bias to keep transactions
76
tracesSampleRate: 1.0,
Lines changed: 37 additions & 56 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 { collectStreamedSpans, getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils';
33
import { APP_NAME } from '../constants';
44

55
test.describe('server - instrumentation API error capture', () => {
@@ -8,15 +8,15 @@ test.describe('server - instrumentation API error capture', () => {
88
return errorEvent.exception?.values?.[0]?.value === 'Loader error for testing';
99
});
1010

11-
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
12-
return transactionEvent.transaction === 'GET /performance/error-loader';
11+
const spanPromise = waitForStreamedSpan(APP_NAME, span => {
12+
return span.name === 'GET /performance/error-loader' && span.is_segment;
1313
});
1414

1515
await page.goto(`/performance/error-loader`).catch(() => {
1616
// Expected to fail due to loader error
1717
});
1818

19-
const [error, transaction] = await Promise.all([errorPromise, txPromise]);
19+
const [error, span] = await Promise.all([errorPromise, spanPromise]);
2020

2121
// Verify the error was captured with correct mechanism and transaction name
2222
expect(error).toMatchObject({
@@ -36,58 +36,51 @@ test.describe('server - instrumentation API error capture', () => {
3636
});
3737

3838
// Verify the transaction was also created with correct attributes
39-
expect(transaction).toMatchObject({
40-
transaction: 'GET /performance/error-loader',
41-
contexts: {
42-
trace: {
43-
op: 'http.server',
44-
origin: 'auto.http.react_router.instrumentation_api',
45-
},
46-
},
47-
});
39+
expect(span.name).toBe('GET /performance/error-loader');
40+
expect(getSpanOp(span)).toBe('http.server');
41+
expect(span.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api');
4842
});
4943

50-
test('should include loader span in transaction even when loader throws', async ({ page }) => {
51-
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
52-
return transactionEvent.transaction === 'GET /performance/error-loader';
53-
});
44+
test('should include loader span in the segment even when loader throws', async ({ page }) => {
45+
const spansPromise = collectStreamedSpans(APP_NAME, spansOfTrace =>
46+
spansOfTrace.some(span => span.name === 'GET /performance/error-loader' && span.is_segment),
47+
);
5448

5549
await page.goto(`/performance/error-loader`).catch(() => {
5650
// Expected to fail due to loader error
5751
});
5852

59-
const transaction = await txPromise;
53+
const spans = await spansPromise;
6054

6155
// Find the loader span
62-
const loaderSpan = transaction?.spans?.find(span => span.data?.['code.function.name'] === 'loader');
56+
const loaderSpan = spans.find(span => span.attributes['code.function.name']?.value === 'loader');
6357

64-
expect(loaderSpan).toMatchObject({
65-
data: {
66-
'sentry.origin': 'auto.function.react_router.instrumentation_api',
67-
'sentry.op': 'function',
68-
'code.function.name': 'loader',
69-
},
70-
op: 'function',
58+
expect(loaderSpan).toBeDefined();
59+
expect(getSpanOp(loaderSpan!)).toBe('function');
60+
expect(loaderSpan!.attributes).toMatchObject({
61+
'sentry.origin': { value: 'auto.function.react_router.instrumentation_api', type: 'string' },
62+
'sentry.op': { value: 'function', type: 'string' },
63+
'code.function.name': { value: 'loader', type: 'string' },
7164
});
7265
});
7366

74-
test('error and transaction should share the same trace', async ({ page }) => {
67+
test('error and segment span should share the same trace', async ({ page }) => {
7568
const errorPromise = waitForError(APP_NAME, async errorEvent => {
7669
return errorEvent.exception?.values?.[0]?.value === 'Loader error for testing';
7770
});
7871

79-
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
80-
return transactionEvent.transaction === 'GET /performance/error-loader';
72+
const spanPromise = waitForStreamedSpan(APP_NAME, span => {
73+
return span.name === 'GET /performance/error-loader' && span.is_segment;
8174
});
8275

8376
await page.goto(`/performance/error-loader`).catch(() => {
8477
// Expected to fail due to loader error
8578
});
8679

87-
const [error, transaction] = await Promise.all([errorPromise, txPromise]);
80+
const [error, span] = await Promise.all([errorPromise, spanPromise]);
8881

89-
// Error and transaction should have the same trace_id
90-
expect(error.contexts?.trace?.trace_id).toBe(transaction.contexts?.trace?.trace_id);
82+
// Error and segment span should have the same trace_id
83+
expect(error.contexts?.trace?.trace_id).toBe(span.trace_id);
9184
});
9285

9386
// Skipped in dev: the action error is sometimes captured via the client instrumentation path
@@ -101,14 +94,14 @@ test.describe('server - instrumentation API error capture', () => {
10194
return errorEvent.exception?.values?.[0]?.value === 'Action error for testing';
10295
});
10396

104-
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
105-
return transactionEvent.transaction === 'POST /performance/error-action';
97+
const spanPromise = waitForStreamedSpan(APP_NAME, span => {
98+
return span.name === 'POST /performance/error-action' && span.is_segment;
10699
});
107100

108101
await page.goto(`/performance/error-action`);
109102
await page.getByRole('button', { name: 'Trigger Error' }).click();
110103

111-
const [error, transaction] = await Promise.all([errorPromise, txPromise]);
104+
const [error, span] = await Promise.all([errorPromise, spanPromise]);
112105

113106
expect(error).toMatchObject({
114107
exception: {
@@ -126,31 +119,25 @@ test.describe('server - instrumentation API error capture', () => {
126119
transaction: 'POST /performance/error-action',
127120
});
128121

129-
expect(transaction).toMatchObject({
130-
transaction: 'POST /performance/error-action',
131-
contexts: {
132-
trace: {
133-
op: 'http.server',
134-
origin: 'auto.http.react_router.instrumentation_api',
135-
},
136-
},
137-
});
122+
expect(span.name).toBe('POST /performance/error-action');
123+
expect(getSpanOp(span)).toBe('http.server');
124+
expect(span.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api');
138125
});
139126

140127
test('should capture middleware errors with instrumentation API mechanism', async ({ page }) => {
141128
const errorPromise = waitForError(APP_NAME, async errorEvent => {
142129
return errorEvent.exception?.values?.[0]?.value === 'Middleware error for testing';
143130
});
144131

145-
const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
146-
return transactionEvent.transaction === 'GET /performance/error-middleware';
132+
const spanPromise = waitForStreamedSpan(APP_NAME, span => {
133+
return span.name === 'GET /performance/error-middleware' && span.is_segment;
147134
});
148135

149136
await page.goto(`/performance/error-middleware`).catch(() => {
150137
// Expected to fail due to middleware error
151138
});
152139

153-
const [error, transaction] = await Promise.all([errorPromise, txPromise]);
140+
const [error, span] = await Promise.all([errorPromise, spanPromise]);
154141

155142
expect(error).toMatchObject({
156143
exception: {
@@ -168,14 +155,8 @@ test.describe('server - instrumentation API error capture', () => {
168155
transaction: 'GET /performance/error-middleware',
169156
});
170157

171-
expect(transaction).toMatchObject({
172-
transaction: 'GET /performance/error-middleware',
173-
contexts: {
174-
trace: {
175-
op: 'http.server',
176-
origin: 'auto.http.react_router.instrumentation_api',
177-
},
178-
},
179-
});
158+
expect(span.name).toBe('GET /performance/error-middleware');
159+
expect(getSpanOp(span)).toBe('http.server');
160+
expect(span.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api');
180161
});
181162
});
Lines changed: 54 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,94 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForTransaction } from '@sentry-internal/test-utils';
2+
import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils';
33
import { APP_NAME } from '../constants';
44

55
// Same spans in both runs, from two injectors: the build-time transform in the server bundle, and
66
// the runtime hook in `react-router dev`, where the drivers stay on Node's own loader.
77
test.describe('server - orchestrion db instrumentation', () => {
88
test('instruments ioredis automatically via orchestrion', async ({ page }) => {
9-
const transactionEventPromise = waitForTransaction(APP_NAME, transactionEvent => {
10-
return (
11-
transactionEvent.contexts?.trace?.op === 'http.server' &&
12-
transactionEvent.transaction === 'GET /performance/db-ioredis'
13-
);
14-
});
9+
const spansPromise = collectStreamedSpans(APP_NAME, spansOfTrace =>
10+
spansOfTrace.some(span => span.name === 'GET /performance/db-ioredis' && span.is_segment),
11+
);
1512

1613
await page.goto('/performance/db-ioredis');
1714

18-
const transactionEvent = await transactionEventPromise;
19-
const spans = transactionEvent.spans || [];
15+
const spans = await spansPromise;
16+
const segmentSpan = spans.find(span => span.name === 'GET /performance/db-ioredis' && span.is_segment)!;
2017

21-
// The server transaction must come from the native instrumentation API (not the legacy handler),
18+
// The server segment must come from the native instrumentation API (not the legacy handler),
2219
// proving the orchestrion-injected db spans share context with the React Router server span.
23-
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.react_router.instrumentation_api');
20+
expect(getSpanOp(segmentSpan)).toBe('http.server');
21+
expect(segmentSpan.attributes['sentry.origin']?.value).toBe('auto.http.react_router.instrumentation_api');
2422

25-
expect(spans).toContainEqual(
23+
const childSpans = spans.filter(span => !span.is_segment);
24+
25+
expect(childSpans).toContainEqual(
2626
expect.objectContaining({
27-
op: 'db.query',
28-
origin: 'auto.db.redis',
29-
description: 'set test-key [1 other arguments]',
27+
name: 'set test-key [1 other arguments]',
3028
status: 'ok',
31-
data: expect.objectContaining({
32-
'db.system.name': 'redis',
33-
'db.operation.name': 'set',
34-
'db.query.text': 'set test-key [1 other arguments]',
29+
attributes: expect.objectContaining({
30+
'sentry.op': { value: 'db.query', type: 'string' },
31+
'sentry.origin': { value: 'auto.db.redis', type: 'string' },
32+
'db.system.name': { value: 'redis', type: 'string' },
33+
'db.operation.name': { value: 'set', type: 'string' },
34+
'db.query.text': { value: 'set test-key [1 other arguments]', type: 'string' },
3535
}),
3636
}),
3737
);
38-
expect(spans).toContainEqual(
38+
expect(childSpans).toContainEqual(
3939
expect.objectContaining({
40-
op: 'db.query',
41-
origin: 'auto.db.redis',
42-
description: 'get test-key',
40+
name: 'get test-key',
4341
status: 'ok',
44-
data: expect.objectContaining({
45-
'db.system.name': 'redis',
46-
'db.operation.name': 'get',
47-
'db.query.text': 'get test-key',
42+
attributes: expect.objectContaining({
43+
'sentry.op': { value: 'db.query', type: 'string' },
44+
'sentry.origin': { value: 'auto.db.redis', type: 'string' },
45+
'db.system.name': { value: 'redis', type: 'string' },
46+
'db.operation.name': { value: 'get', type: 'string' },
47+
'db.query.text': { value: 'get test-key', type: 'string' },
4848
}),
4949
}),
5050
);
5151

5252
// Each command maps to exactly one span (no offline-queue duplicate).
53-
const setSpans = spans.filter(span => span.description === 'set test-key [1 other arguments]');
53+
const setSpans = spans.filter(span => span.name === 'set test-key [1 other arguments]');
5454
expect(setSpans).toHaveLength(1);
5555

56-
// Every db span nests under the native instrumentation-API http.server transaction.
57-
const rootSpanId = transactionEvent.contexts?.trace?.span_id;
58-
const spanIds = new Set([rootSpanId, ...spans.map(span => span.span_id)]);
59-
const dbSpans = spans.filter(span => span.origin === 'auto.db.redis');
56+
// Every db span nests under the native instrumentation-API http.server segment.
57+
const spanIds = new Set(spans.filter(span => span.trace_id === segmentSpan.trace_id).map(span => span.span_id));
58+
const dbSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.db.redis');
6059
expect(dbSpans.every(span => typeof span.parent_span_id === 'string' && spanIds.has(span.parent_span_id))).toBe(
6160
true,
6261
);
6362
});
6463

64+
// Under span streaming the mysql span name is the query summary, so both queries below are named
65+
// `SELECT`. `db.query.text` is what tells them apart.
6566
test('instruments mysql automatically via orchestrion', async ({ page }) => {
66-
const transactionEventPromise = waitForTransaction(APP_NAME, transactionEvent => {
67-
return (
68-
transactionEvent.contexts?.trace?.op === 'http.server' &&
69-
transactionEvent.transaction === 'GET /performance/db-mysql'
70-
);
71-
});
67+
const spansPromise = collectStreamedSpans(APP_NAME, spansOfTrace =>
68+
spansOfTrace.some(span => span.name === 'GET /performance/db-mysql' && span.is_segment),
69+
);
7270

7371
await page.goto('/performance/db-mysql');
7472

75-
const transactionEvent = await transactionEventPromise;
76-
const spans = transactionEvent.spans || [];
73+
const spans = await spansPromise;
7774

78-
expect(spans).toContainEqual(
79-
expect.objectContaining({
80-
op: 'db',
81-
origin: 'auto.db.mysql',
82-
description: 'SELECT 1 + 1 AS solution',
83-
status: 'ok',
84-
data: expect.objectContaining({
85-
'db.system.name': 'mysql',
86-
'db.query.text': 'SELECT 1 + 1 AS solution',
87-
'db.user': 'root',
88-
'db.connection_string': expect.any(String),
89-
'server.address': expect.any(String),
90-
'server.port': 3306,
75+
for (const queryText of ['SELECT 1 + 1 AS solution', 'SELECT NOW()']) {
76+
expect(spans).toContainEqual(
77+
expect.objectContaining({
78+
name: 'SELECT',
79+
status: 'ok',
80+
attributes: expect.objectContaining({
81+
'sentry.op': { value: 'db', type: 'string' },
82+
'sentry.origin': { value: 'auto.db.mysql', type: 'string' },
83+
'db.system.name': { value: 'mysql', type: 'string' },
84+
'db.query.text': { value: queryText, type: 'string' },
85+
'db.user': { value: 'root', type: 'string' },
86+
'db.connection_string': { value: expect.any(String), type: 'string' },
87+
'server.address': { value: expect.any(String), type: 'string' },
88+
'server.port': { value: 3306, type: 'integer' },
89+
}),
9190
}),
92-
}),
93-
);
94-
expect(spans).toContainEqual(
95-
expect.objectContaining({
96-
op: 'db',
97-
origin: 'auto.db.mysql',
98-
description: 'SELECT NOW()',
99-
status: 'ok',
100-
data: expect.objectContaining({
101-
'db.system.name': 'mysql',
102-
'db.query.text': 'SELECT NOW()',
103-
'db.user': 'root',
104-
'db.connection_string': expect.any(String),
105-
'server.address': expect.any(String),
106-
'server.port': 3306,
107-
}),
108-
}),
109-
);
91+
);
92+
}
11093
});
11194
});

0 commit comments

Comments
 (0)