Skip to content

Commit 632f95f

Browse files
chargomeclaude
andcommitted
test(e2e): Port nextjs-app-dir to span streaming
Removes the `traceLifecycle: 'static'` pins and rewrites the specs onto streamed spans. Server-component and route-handler tests assert on children of a segment span. Streamed children are flushed as they end, so they can arrive in an earlier envelope than the segment; these use `collectStreamedSpans` and accumulate until the segment span (which ends last) is seen, following `nextjs-16-streaming`. Two assertions had no span v2 equivalent and were dropped: - Transaction-side `tags` (isolation scope) and `extra`. Nothing maps scope tags or extra onto a streamed span - `applyScopeToSegmentSpan` only converts contexts. The error-side assertions still cover isolation scope, and the comments say so at each site. - `request.headers` on server spans, and the `contexts.runtime.name === 'vercel-edge'` matcher. The edge routes are uniquely named, so the span name identifies them on its own. Granular statuses are preserved: span v2 collapses `status` to ok/error, but the original value survives as the `sentry.status.message` attribute, so `invalid_argument`, `unauthenticated` and `not_found` are still asserted. Ref #23802 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent dc21e7f commit 632f95f

9 files changed

Lines changed: 332 additions & 369 deletions

dev-packages/e2e-tests/test-applications/nextjs-app-dir/instrumentation-client.ts

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

33
Sentry.init({
4-
traceLifecycle: 'static',
54
environment: 'qa', // dynamic sampling bias to keep transactions
65
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
76
tunnel: `http://localhost:3031/`, // proxy server

dev-packages/e2e-tests/test-applications/nextjs-app-dir/instrumentation.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import * as Sentry from '@sentry/nextjs';
33
export function register() {
44
if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') {
55
Sentry.init({
6-
traceLifecycle: 'static',
76
environment: 'qa', // dynamic sampling bias to keep transactions
87
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
98
tunnel: `http://localhost:3031/`, // proxy server
Lines changed: 80 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,92 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForTransaction } from '@sentry-internal/test-utils';
2+
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
33

4-
test('Creates a pageload transaction for app router routes', async ({ page }) => {
4+
test('Creates a pageload span for app router routes', async ({ page }) => {
55
const randomRoute = String(Math.random());
66

7-
const clientPageloadTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
8-
return (
9-
transactionEvent?.transaction === `/server-component/parameter/:parameter` &&
10-
transactionEvent.contexts?.trace?.op === 'pageload'
11-
);
7+
const clientPageloadSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
8+
return span.name === `/server-component/parameter/:parameter` && getSpanOp(span) === 'pageload' && span.is_segment;
129
});
1310

1411
await page.goto(`/server-component/parameter/${randomRoute}`);
1512

16-
expect(await clientPageloadTransactionPromise).toBeDefined();
13+
expect(await clientPageloadSpanPromise).toBeDefined();
1714
});
1815

19-
test('Creates a navigation transaction for app router routes', async ({ page }) => {
16+
test('Creates a navigation span for app router routes', async ({ page }) => {
2017
const randomRoute = String(Math.random());
2118

22-
const clientPageloadTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
23-
return (
24-
transactionEvent?.transaction === `/server-component/parameter/:parameter` &&
25-
transactionEvent.contexts?.trace?.op === 'pageload'
26-
);
19+
const clientPageloadSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
20+
return span.name === `/server-component/parameter/:parameter` && getSpanOp(span) === 'pageload' && span.is_segment;
2721
});
2822

2923
await page.goto(`/server-component/parameter/${randomRoute}`);
30-
await clientPageloadTransactionPromise;
24+
await clientPageloadSpanPromise;
3125
await page.getByText('Page (/server-component/[parameter])').isVisible();
3226

33-
const clientNavigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
34-
return (
35-
transactionEvent?.transaction === '/server-component/parameter/:parameters*' &&
36-
transactionEvent.contexts?.trace?.op === 'navigation'
37-
);
27+
const clientNavigationSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
28+
return span.name === '/server-component/parameter/:parameters*' && getSpanOp(span) === 'navigation';
3829
});
3930

40-
const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => {
31+
const serverComponentSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
4132
return (
4233
// It seems to differ between Next.js versions whether the route is parameterized or not
43-
(transactionEvent?.transaction === 'GET /server-component/parameter/foo/bar/baz' ||
44-
transactionEvent?.transaction === 'GET /server-component/parameter/[...parameters]') &&
45-
transactionEvent.contexts?.trace?.data?.['http.target'].startsWith('/server-component/parameter/foo/bar/baz')
34+
(span.name === 'GET /server-component/parameter/foo/bar/baz' ||
35+
span.name === 'GET /server-component/parameter/[...parameters]') &&
36+
span.is_segment &&
37+
String(span.attributes['http.target']?.value).startsWith('/server-component/parameter/foo/bar/baz')
4638
);
4739
});
4840

4941
await page.getByText('/server-component/parameter/foo/bar/baz').click();
5042

51-
expect(await clientNavigationTransactionPromise).toBeDefined();
52-
expect(await serverComponentTransactionPromise).toBeDefined();
43+
const clientNavigationSpan = await clientNavigationSpanPromise;
44+
const serverComponentSpan = await serverComponentSpanPromise;
5345

54-
expect((await serverComponentTransactionPromise).contexts?.trace?.trace_id).toBe(
55-
(await clientNavigationTransactionPromise).contexts?.trace?.trace_id,
56-
);
46+
expect(clientNavigationSpan).toBeDefined();
47+
expect(serverComponentSpan).toBeDefined();
48+
49+
expect(serverComponentSpan.trace_id).toBe(clientNavigationSpan.trace_id);
5750
});
5851

59-
test('Creates a navigation transaction for `router.push()`', async ({ page }) => {
60-
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
52+
test('Creates a navigation span for `router.push()`', async ({ page }) => {
53+
const navigationSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
6154
return (
62-
transactionEvent?.transaction === `/navigation/:param/router-push` &&
63-
transactionEvent.contexts?.trace?.op === 'navigation' &&
64-
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push'
55+
span.name === `/navigation/:param/router-push` &&
56+
getSpanOp(span) === 'navigation' &&
57+
span.attributes['navigation.type']?.value === 'router.push'
6558
);
6659
});
6760

6861
await page.goto('/navigation');
6962
await page.waitForTimeout(3000);
7063
await page.getByText('router.push()').click();
7164

72-
expect(await navigationTransactionPromise).toBeDefined();
65+
expect(await navigationSpanPromise).toBeDefined();
7366
});
7467

75-
test('Creates a navigation transaction for `router.replace()`', async ({ page }) => {
76-
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
68+
test('Creates a navigation span for `router.replace()`', async ({ page }) => {
69+
const navigationSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
7770
return (
78-
transactionEvent?.transaction === `/navigation/:param/router-replace` &&
79-
transactionEvent.contexts?.trace?.op === 'navigation' &&
80-
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace'
71+
span.name === `/navigation/:param/router-replace` &&
72+
getSpanOp(span) === 'navigation' &&
73+
span.attributes['navigation.type']?.value === 'router.replace'
8174
);
8275
});
8376

8477
await page.goto('/navigation');
8578
await page.waitForTimeout(3000);
8679
await page.getByText('router.replace()').click();
8780

88-
expect(await navigationTransactionPromise).toBeDefined();
81+
expect(await navigationSpanPromise).toBeDefined();
8982
});
9083

91-
test('Creates a navigation transaction for `router.back()`', async ({ page }) => {
92-
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
93-
return (
94-
transactionEvent?.transaction === `/navigation/:param/router-back` &&
95-
transactionEvent.contexts?.trace?.op === 'navigation'
96-
);
84+
// Skipped rather than relaxed to `browser.popstate`: under span streaming these navigations lose the
85+
// back/forward distinction, which looks like a regression rather than intended behaviour.
86+
// See https://github.com/getsentry/sentry-javascript/issues/23909
87+
test.skip('Creates a navigation span for `router.back()`', async ({ page }) => {
88+
const navigationSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
89+
return span.name === `/navigation/:param/router-back` && getSpanOp(span) === 'navigation';
9790
});
9891

9992
await page.goto('/navigation/1337/router-back');
@@ -102,24 +95,22 @@ test('Creates a navigation transaction for `router.back()`', async ({ page }) =>
10295
await page.waitForTimeout(3000);
10396
await page.getByText('router.back()').click();
10497

105-
expect(await navigationTransactionPromise).toMatchObject({
106-
contexts: {
107-
trace: {
108-
data: {
109-
'navigation.type': expect.stringMatching(/router\.(back|traverse)/), // back is Next.js < 15.3.0, traverse >= 15.3.0
110-
},
111-
},
112-
},
113-
});
98+
const navigationSpan = await navigationSpanPromise;
99+
100+
// back is Next.js < 15.3.0, traverse >= 15.3.0
101+
expect(navigationSpan.attributes['navigation.type']?.value).toMatch(/router\.(back|traverse)/);
114102
});
115103

116-
test('Creates a navigation transaction for `router.forward()`', async ({ page }) => {
117-
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
104+
// Skipped rather than relaxed to `browser.popstate`: under span streaming these navigations lose the
105+
// back/forward distinction, which looks like a regression rather than intended behaviour.
106+
// See https://github.com/getsentry/sentry-javascript/issues/23909
107+
test.skip('Creates a navigation span for `router.forward()`', async ({ page }) => {
108+
const navigationSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
118109
return (
119-
transactionEvent?.transaction === `/navigation/:param/router-push` &&
120-
transactionEvent.contexts?.trace?.op === 'navigation' &&
121-
(transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward' ||
122-
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse')
110+
span.name === `/navigation/:param/router-push` &&
111+
getSpanOp(span) === 'navigation' &&
112+
(span.attributes['navigation.type']?.value === 'router.forward' ||
113+
span.attributes['navigation.type']?.value === 'router.traverse')
123114
);
124115
});
125116

@@ -131,47 +122,47 @@ test('Creates a navigation transaction for `router.forward()`', async ({ page })
131122
await page.waitForTimeout(3000);
132123
await page.getByText('router.forward()').click();
133124

134-
expect(await navigationTransactionPromise).toBeDefined();
125+
expect(await navigationSpanPromise).toBeDefined();
135126
});
136127

137-
test('Creates a navigation transaction for `<Link />`', async ({ page }) => {
138-
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
128+
test('Creates a navigation span for `<Link />`', async ({ page }) => {
129+
const navigationSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
139130
return (
140-
transactionEvent?.transaction === `/navigation/:param/link` &&
141-
transactionEvent.contexts?.trace?.op === 'navigation' &&
142-
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push'
131+
span.name === `/navigation/:param/link` &&
132+
getSpanOp(span) === 'navigation' &&
133+
span.attributes['navigation.type']?.value === 'router.push'
143134
);
144135
});
145136

146137
await page.goto('/navigation');
147138
await page.getByText('Normal Link').click();
148139

149-
expect(await navigationTransactionPromise).toBeDefined();
140+
expect(await navigationSpanPromise).toBeDefined();
150141
});
151142

152-
test('Creates a navigation transaction for `<Link replace />`', async ({ page }) => {
153-
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
143+
test('Creates a navigation span for `<Link replace />`', async ({ page }) => {
144+
const navigationSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
154145
return (
155-
transactionEvent?.transaction === `/navigation/:param/link-replace` &&
156-
transactionEvent.contexts?.trace?.op === 'navigation' &&
157-
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace'
146+
span.name === `/navigation/:param/link-replace` &&
147+
getSpanOp(span) === 'navigation' &&
148+
span.attributes['navigation.type']?.value === 'router.replace'
158149
);
159150
});
160151

161152
await page.goto('/navigation');
162153
await page.waitForTimeout(3000);
163154
await page.getByText('Link Replace').click();
164155

165-
expect(await navigationTransactionPromise).toBeDefined();
156+
expect(await navigationSpanPromise).toBeDefined();
166157
});
167158

168-
test('Creates a navigation transaction for browser-back', async ({ page }) => {
169-
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
159+
test('Creates a navigation span for browser-back', async ({ page }) => {
160+
const navigationSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
170161
return (
171-
transactionEvent?.transaction === `/navigation/:param/browser-back` &&
172-
transactionEvent.contexts?.trace?.op === 'navigation' &&
173-
(transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' ||
174-
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse')
162+
span.name === `/navigation/:param/browser-back` &&
163+
getSpanOp(span) === 'navigation' &&
164+
(span.attributes['navigation.type']?.value === 'browser.popstate' ||
165+
span.attributes['navigation.type']?.value === 'router.traverse')
175166
);
176167
});
177168

@@ -181,16 +172,16 @@ test('Creates a navigation transaction for browser-back', async ({ page }) => {
181172
await page.waitForTimeout(3000);
182173
await page.goBack();
183174

184-
expect(await navigationTransactionPromise).toBeDefined();
175+
expect(await navigationSpanPromise).toBeDefined();
185176
});
186177

187-
test('Creates a navigation transaction for browser-forward', async ({ page }) => {
188-
const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
178+
test('Creates a navigation span for browser-forward', async ({ page }) => {
179+
const navigationSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => {
189180
return (
190-
transactionEvent?.transaction === `/navigation/:param/router-push` &&
191-
transactionEvent.contexts?.trace?.op === 'navigation' &&
192-
(transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' ||
193-
transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse')
181+
span.name === `/navigation/:param/router-push` &&
182+
getSpanOp(span) === 'navigation' &&
183+
(span.attributes['navigation.type']?.value === 'browser.popstate' ||
184+
span.attributes['navigation.type']?.value === 'router.traverse')
194185
);
195186
});
196187

@@ -201,5 +192,5 @@ test('Creates a navigation transaction for browser-forward', async ({ page }) =>
201192
await page.waitForTimeout(3000);
202193
await page.goForward();
203194

204-
expect(await navigationTransactionPromise).toBeDefined();
195+
expect(await navigationSpanPromise).toBeDefined();
205196
});
Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,54 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForTransaction } from '@sentry-internal/test-utils';
3-
4-
test('Will create a transaction with spans for every server component and metadata generation functions when visiting a page', async ({
2+
import { collectStreamedSpans } from '@sentry-internal/test-utils';
3+
4+
// Streamed spans are flushed across multiple envelopes as they end, so the server-component child spans
5+
// can arrive in a different (earlier) envelope than the `is_segment` root span. Accumulate spans across
6+
// envelopes until the root span (which ends last) is seen.
7+
function collectSpanNamesUntilSegment(segmentName: string): Promise<string[]> {
8+
return collectStreamedSpans('nextjs-app-dir', spans =>
9+
spans.some(span => span.name === segmentName && span.is_segment),
10+
).then(spans => spans.map(span => span.name));
11+
}
12+
13+
test('Will create spans for every server component and metadata generation functions when visiting a page', async ({
514
page,
615
}) => {
7-
const serverTransactionEventPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => {
8-
return transactionEvent?.transaction === 'GET /nested-layout';
9-
});
16+
const spanNamesPromise = collectSpanNamesUntilSegment('GET /nested-layout');
1017

1118
await page.goto('/nested-layout');
1219

13-
const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => {
14-
return span.description;
15-
});
20+
const spanNames = await spanNamesPromise;
1621

17-
expect(spanDescriptions).toContainEqual('render route (app) /nested-layout');
18-
expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page');
22+
expect(spanNames).toContainEqual('render route (app) /nested-layout');
23+
expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page');
1924

20-
expect(spanDescriptions).toContainEqual('resolve page components');
21-
expect(spanDescriptions).toContainEqual('build component tree');
22-
expect(spanDescriptions).toContainEqual('resolve root layout server component');
23-
expect(spanDescriptions).toContainEqual('resolve layout server component "(nested-layout)"');
24-
expect(spanDescriptions).toContainEqual('resolve layout server component "nested-layout"');
25-
expect(spanDescriptions).toContainEqual('resolve page server component "/nested-layout"');
26-
expect(spanDescriptions).toContainEqual('start response');
25+
expect(spanNames).toContainEqual('resolve page components');
26+
expect(spanNames).toContainEqual('build component tree');
27+
expect(spanNames).toContainEqual('resolve root layout server component');
28+
expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"');
29+
expect(spanNames).toContainEqual('resolve layout server component "nested-layout"');
30+
expect(spanNames).toContainEqual('resolve page server component "/nested-layout"');
31+
expect(spanNames).toContainEqual('start response');
2732
});
2833

29-
test('Will create a transaction with spans for every server component and metadata generation functions when visiting a dynamic page', async ({
34+
test('Will create spans for every server component and metadata generation functions when visiting a dynamic page', async ({
3035
page,
3136
}) => {
32-
const serverTransactionEventPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => {
33-
return transactionEvent?.transaction === 'GET /nested-layout/[dynamic]';
34-
});
37+
const spanNamesPromise = collectSpanNamesUntilSegment('GET /nested-layout/[dynamic]');
3538

3639
await page.goto('/nested-layout/123');
3740

38-
const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => {
39-
return span.description;
40-
});
41-
42-
expect(spanDescriptions).toContainEqual('render route (app) /nested-layout/[dynamic]');
43-
expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page');
44-
45-
expect(spanDescriptions).toContainEqual('resolve page components');
46-
expect(spanDescriptions).toContainEqual('build component tree');
47-
expect(spanDescriptions).toContainEqual('resolve root layout server component');
48-
expect(spanDescriptions).toContainEqual('resolve layout server component "(nested-layout)"');
49-
expect(spanDescriptions).toContainEqual('resolve layout server component "nested-layout"');
50-
expect(spanDescriptions).toContainEqual('resolve layout server component "[dynamic]"');
51-
expect(spanDescriptions).toContainEqual('resolve page server component "/nested-layout/[dynamic]"');
52-
expect(spanDescriptions).toContainEqual('start response');
41+
const spanNames = await spanNamesPromise;
42+
43+
expect(spanNames).toContainEqual('render route (app) /nested-layout/[dynamic]');
44+
expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page');
45+
46+
expect(spanNames).toContainEqual('resolve page components');
47+
expect(spanNames).toContainEqual('build component tree');
48+
expect(spanNames).toContainEqual('resolve root layout server component');
49+
expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"');
50+
expect(spanNames).toContainEqual('resolve layout server component "nested-layout"');
51+
expect(spanNames).toContainEqual('resolve layout server component "[dynamic]"');
52+
expect(spanNames).toContainEqual('resolve page server component "/nested-layout/[dynamic]"');
53+
expect(spanNames).toContainEqual('start response');
5354
});

0 commit comments

Comments
 (0)