Skip to content

Commit 4c54a79

Browse files
author
Luca Forstner
authored
feat(nextjs): Use spans generated by Next.js for App Router (#12729)
1 parent d6cd5ca commit 4c54a79

File tree

23 files changed

+391
-318
lines changed

23 files changed

+391
-318
lines changed

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,27 @@
88

99
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
1010

11+
### Important Changes
12+
13+
- **feat(nextjs): Use spans generated by Next.js for App Router (#12729)**
14+
15+
Previously, the `@sentry/nextjs` SDK automatically recorded spans in the form of transactions for each of your top-level
16+
server components (pages, layouts, ...). This approach had a few drawbacks, the main ones being that traces didn't have
17+
a root span, and more importantly, if you had data stream to the client, its duration was not captured because the
18+
server component spans had finished before the data could finish streaming.
19+
20+
With this release, we will capture the duration of App Router requests in their entirety as a single transaction with
21+
server component spans being descendants of that transaction. This means you will get more data that is also more
22+
accurate. Note that this does not apply to the Edge runtime. For the Edge runtime, the SDK will emit transactions as it
23+
has before.
24+
25+
Generally speaking, this change means that you will see less _transactions_ and more _spans_ in Sentry. Your will no
26+
longer receive server component transactions like `Page Server Component (/path/to/route)` (unless using the Edge
27+
runtime), and you will instead receive transactions for your App Router SSR requests that look like
28+
`GET /path/to/route`.
29+
30+
If you are on Sentry SaaS, this may have an effect on your quota consumption: Less transactions, more spans.
31+
1132
## 8.15.0
1233

1334
- feat(core): allow unregistering callback through `on` (#11710)

dev-packages/e2e-tests/test-applications/create-next-app/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
"test:assert": "pnpm test:prod && pnpm test:dev"
1313
},
1414
"dependencies": {
15-
"@next/font": "13.0.7",
1615
"@sentry/nextjs": "latest || *",
1716
"@types/node": "18.11.17",
1817
"@types/react": "18.0.26",

dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/test-outgoing-fetch-external-disallowed/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { NextResponse } from 'next/server';
33
export const dynamic = 'force-dynamic';
44

55
export async function GET() {
6-
const data = await fetch(`http://localhost:3030/propagation/test-outgoing-fetch-external-disallowed/check`).then(
7-
res => res.json(),
8-
);
6+
const data = await fetch(`http://localhost:3030/propagation/test-outgoing-fetch-external-disallowed/check`, {
7+
cache: 'no-store',
8+
}).then(res => res.json());
99
return NextResponse.json(data);
1010
}

dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/test-outgoing-fetch/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server';
33
export const dynamic = 'force-dynamic';
44

55
export async function GET() {
6-
const data = await fetch(`http://localhost:3030/propagation/test-outgoing-fetch/check`).then(res => res.json());
6+
const data = await fetch(`http://localhost:3030/propagation/test-outgoing-fetch/check`, { cache: 'no-store' }).then(
7+
res => res.json(),
8+
);
79
return NextResponse.json(data);
810
}

dev-packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@ if (!testEnv) {
55
throw new Error('No test env defined');
66
}
77

8-
const config = getPlaywrightConfig({
9-
startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030',
10-
port: 3030,
11-
});
8+
const config = getPlaywrightConfig(
9+
{
10+
startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030',
11+
port: 3030,
12+
},
13+
{
14+
// This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize
15+
workers: '100%',
16+
},
17+
);
1218

1319
export default config;

dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
import { expect, test } from '@playwright/test';
22
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
33

4-
test('Should send a transaction event for a generateMetadata() function invokation', async ({ page }) => {
5-
const testTitle = 'foobarasdf';
4+
test('Should emit a span for a generateMetadata() function invokation', async ({ page }) => {
5+
const testTitle = 'should-emit-span';
66

77
const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => {
88
return (
9-
transactionEvent?.transaction === 'Page.generateMetadata (/generation-functions)' &&
10-
(transactionEvent.extra?.route_data as any)?.searchParams?.metadataTitle === testTitle
9+
transactionEvent.contexts?.trace?.data?.['http.target'] === `/generation-functions?metadataTitle=${testTitle}`
1110
);
1211
});
1312

1413
await page.goto(`/generation-functions?metadataTitle=${testTitle}`);
1514

16-
expect(await transactionPromise).toBeDefined();
15+
const transaction = await transactionPromise;
16+
17+
expect(transaction.spans).toContainEqual(
18+
expect.objectContaining({
19+
description: 'generateMetadata /generation-functions/page',
20+
origin: 'manual',
21+
parent_span_id: expect.any(String),
22+
span_id: expect.any(String),
23+
status: 'ok',
24+
trace_id: expect.any(String),
25+
}),
26+
);
1727

1828
const pageTitle = await page.title();
1929
expect(pageTitle).toBe(testTitle);
@@ -22,12 +32,12 @@ test('Should send a transaction event for a generateMetadata() function invokati
2232
test('Should send a transaction and an error event for a faulty generateMetadata() function invokation', async ({
2333
page,
2434
}) => {
25-
const testTitle = 'foobarbaz';
35+
const testTitle = 'should-emit-error';
2636

2737
const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => {
2838
return (
29-
transactionEvent.transaction === 'Page.generateMetadata (/generation-functions)' &&
30-
(transactionEvent.extra?.route_data as any)?.searchParams?.metadataTitle === testTitle
39+
transactionEvent.contexts?.trace?.data?.['http.target'] ===
40+
`/generation-functions?metadataTitle=${testTitle}&shouldThrowInGenerateMetadata=1`
3141
);
3242
});
3343

@@ -54,14 +64,23 @@ test('Should send a transaction event for a generateViewport() function invokati
5464

5565
const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => {
5666
return (
57-
transactionEvent?.transaction === 'Page.generateViewport (/generation-functions)' &&
58-
(transactionEvent.extra?.route_data as any)?.searchParams?.viewportThemeColor === testTitle
67+
transactionEvent.contexts?.trace?.data?.['http.target'] ===
68+
`/generation-functions?viewportThemeColor=${testTitle}`
5969
);
6070
});
6171

6272
await page.goto(`/generation-functions?viewportThemeColor=${testTitle}`);
6373

64-
expect(await transactionPromise).toBeDefined();
74+
expect((await transactionPromise).spans).toContainEqual(
75+
expect.objectContaining({
76+
description: 'generateViewport /generation-functions/page',
77+
origin: 'manual',
78+
parent_span_id: expect.any(String),
79+
span_id: expect.any(String),
80+
status: 'ok',
81+
trace_id: expect.any(String),
82+
}),
83+
);
6584
});
6685

6786
test('Should send a transaction and an error event for a faulty generateViewport() function invokation', async ({
@@ -71,8 +90,8 @@ test('Should send a transaction and an error event for a faulty generateViewport
7190

7291
const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => {
7392
return (
74-
transactionEvent?.transaction === 'Page.generateViewport (/generation-functions)' &&
75-
(transactionEvent.extra?.route_data as any)?.searchParams?.viewportThemeColor === testTitle
93+
transactionEvent.contexts?.trace?.data?.['http.target'] ===
94+
`/generation-functions?viewportThemeColor=${testTitle}&shouldThrowInGenerateViewport=1`
7695
);
7796
});
7897

@@ -97,8 +116,8 @@ test('Should send a transaction event with correct status for a generateMetadata
97116

98117
const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => {
99118
return (
100-
transactionEvent?.transaction === 'Page.generateMetadata (/generation-functions/with-redirect)' &&
101-
(transactionEvent.extra?.route_data as any)?.searchParams?.metadataTitle === testTitle
119+
transactionEvent.contexts?.trace?.data?.['http.target'] ===
120+
`/generation-functions/with-redirect?metadataTitle=${testTitle}`
102121
);
103122
});
104123

@@ -114,8 +133,8 @@ test('Should send a transaction event with correct status for a generateMetadata
114133

115134
const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => {
116135
return (
117-
transactionEvent?.transaction === 'Page.generateMetadata (/generation-functions/with-notfound)' &&
118-
(transactionEvent.extra?.route_data as any)?.searchParams?.metadataTitle === testTitle
136+
transactionEvent.contexts?.trace?.data?.['http.target'] ===
137+
`/generation-functions/with-notfound?metadataTitle=${testTitle}`
119138
);
120139
});
121140

dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { waitForTransaction } from '@sentry-internal/test-utils';
33

44
test('Should send a transaction with a fetch span', async ({ page }) => {
55
const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => {
6-
return transactionEvent?.transaction === 'Page Server Component (/request-instrumentation)';
6+
return transactionEvent?.transaction === 'GET /request-instrumentation';
77
});
88

99
await page.goto(`/request-instrumentation`);

dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export default async function Page() {
66
}
77

88
export async function generateMetadata() {
9-
(await fetch('http://example.com/')).text();
9+
(await fetch('http://example.com/', { cache: 'no-store' })).text();
1010

1111
return {
1212
title: 'my title',

dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@ if (!testEnv) {
55
throw new Error('No test env defined');
66
}
77

8-
const config = getPlaywrightConfig({
9-
startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030',
10-
port: 3030,
11-
});
8+
const config = getPlaywrightConfig(
9+
{
10+
startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030',
11+
port: 3030,
12+
},
13+
{
14+
// This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize
15+
workers: '100%',
16+
},
17+
);
1218

1319
export default config;
Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
11
import { expect, test } from '@playwright/test';
22
import { waitForTransaction } from '@sentry-internal/test-utils';
33

4-
test('all server component transactions should be attached to the pageload request span', async ({ page }) => {
5-
const pageServerComponentTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
6-
return transactionEvent?.transaction === 'Page Server Component (/pageload-tracing)';
7-
});
8-
9-
const layoutServerComponentTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
10-
return transactionEvent?.transaction === 'Layout Server Component (/pageload-tracing)';
11-
});
12-
13-
const metadataTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
14-
return transactionEvent?.transaction === 'Page.generateMetadata (/pageload-tracing)';
4+
test('App router transactions should be attached to the pageload request span', async ({ page }) => {
5+
const serverTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
6+
return transactionEvent?.transaction === 'GET /pageload-tracing';
157
});
168

179
const pageloadTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
@@ -20,18 +12,13 @@ test('all server component transactions should be attached to the pageload reque
2012

2113
await page.goto(`/pageload-tracing`);
2214

23-
const [pageServerComponentTransaction, layoutServerComponentTransaction, metadataTransaction, pageloadTransaction] =
24-
await Promise.all([
25-
pageServerComponentTransactionPromise,
26-
layoutServerComponentTransactionPromise,
27-
metadataTransactionPromise,
28-
pageloadTransactionPromise,
29-
]);
15+
const [serverTransaction, pageloadTransaction] = await Promise.all([
16+
serverTransactionPromise,
17+
pageloadTransactionPromise,
18+
]);
3019

3120
const pageloadTraceId = pageloadTransaction.contexts?.trace?.trace_id;
3221

3322
expect(pageloadTraceId).toBeTruthy();
34-
expect(pageServerComponentTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId);
35-
expect(layoutServerComponentTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId);
36-
expect(metadataTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId);
23+
expect(serverTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId);
3724
});

0 commit comments

Comments
 (0)