diff --git a/CHANGELOG.md b/CHANGELOG.md index 90fb61d73ae2..6929bdfb48ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **feat(nextjs): Use spans generated by Next.js for App Router (#12729)** + +Previously, the `@sentry/nextjs` SDK automatically recorded spans in the form of transactions for each of your top-level +server components (pages, layouts, ...). This approach had a few drawbacks, the main ones being that traces didn't have +a root span, and more importantly, if you had data stream to the client, its duration was not captured because the +server component spans had finished before the data could finish streaming. + +With this release, we will capture the duration of App Router requests in their entirety as a single transaction with +server component spans being descendants of that transaction. This means you will get more data that is also more +accurate. Note that this does not apply to the Edge runtime. For the Edge runtime, the SDK will emit transactions as it +has before. + +Generally speaking, this change means that you will see less _transactions_ and more _spans_ in Sentry. Your will no +longer receive server component transactions like `Page Server Component (/path/to/route)` (unless using the Edge +runtime), and you will instead receive transactions for your App Router SSR requests that look like +`GET /path/to/route`. + +If you are on Sentry SaaS, this may have an effect on your quota consumption: Less transactions, more spans. + ## 8.15.0 - feat(core): allow unregistering callback through `on` (#11710) diff --git a/dev-packages/e2e-tests/test-applications/create-next-app/package.json b/dev-packages/e2e-tests/test-applications/create-next-app/package.json index 2c0945051ee5..9c240942b3b7 100644 --- a/dev-packages/e2e-tests/test-applications/create-next-app/package.json +++ b/dev-packages/e2e-tests/test-applications/create-next-app/package.json @@ -12,7 +12,6 @@ "test:assert": "pnpm test:prod && pnpm test:dev" }, "dependencies": { - "@next/font": "13.0.7", "@sentry/nextjs": "latest || *", "@types/node": "18.11.17", "@types/react": "18.0.26", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/test-outgoing-fetch-external-disallowed/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/test-outgoing-fetch-external-disallowed/route.ts index b57d873f3ce7..be38866a9e94 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/test-outgoing-fetch-external-disallowed/route.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/test-outgoing-fetch-external-disallowed/route.ts @@ -3,8 +3,8 @@ import { NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; export async function GET() { - const data = await fetch(`http://localhost:3030/propagation/test-outgoing-fetch-external-disallowed/check`).then( - res => res.json(), - ); + const data = await fetch(`http://localhost:3030/propagation/test-outgoing-fetch-external-disallowed/check`, { + cache: 'no-store', + }).then(res => res.json()); return NextResponse.json(data); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/test-outgoing-fetch/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/test-outgoing-fetch/route.ts index df9f2e772931..4ee7fac97f83 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/test-outgoing-fetch/route.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/propagation/test-outgoing-fetch/route.ts @@ -3,6 +3,8 @@ import { NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; export async function GET() { - const data = await fetch(`http://localhost:3030/propagation/test-outgoing-fetch/check`).then(res => res.json()); + const data = await fetch(`http://localhost:3030/propagation/test-outgoing-fetch/check`, { cache: 'no-store' }).then( + res => res.json(), + ); return NextResponse.json(data); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts index c675d003853a..8448829443d6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts @@ -5,9 +5,15 @@ if (!testEnv) { throw new Error('No test env defined'); } -const config = getPlaywrightConfig({ - startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', - port: 3030, -}); +const config = getPlaywrightConfig( + { + startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', + port: 3030, + }, + { + // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize + workers: '100%', + }, +); export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts index 7739e9ac17de..303582ec1b24 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts @@ -1,19 +1,29 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; -test('Should send a transaction event for a generateMetadata() function invokation', async ({ page }) => { - const testTitle = 'foobarasdf'; +test('Should emit a span for a generateMetadata() function invokation', async ({ page }) => { + const testTitle = 'should-emit-span'; const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { return ( - transactionEvent?.transaction === 'Page.generateMetadata (/generation-functions)' && - (transactionEvent.extra?.route_data as any)?.searchParams?.metadataTitle === testTitle + transactionEvent.contexts?.trace?.data?.['http.target'] === `/generation-functions?metadataTitle=${testTitle}` ); }); await page.goto(`/generation-functions?metadataTitle=${testTitle}`); - expect(await transactionPromise).toBeDefined(); + const transaction = await transactionPromise; + + expect(transaction.spans).toContainEqual( + expect.objectContaining({ + description: 'generateMetadata /generation-functions/page', + origin: 'manual', + parent_span_id: expect.any(String), + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }), + ); const pageTitle = await page.title(); expect(pageTitle).toBe(testTitle); @@ -22,12 +32,12 @@ test('Should send a transaction event for a generateMetadata() function invokati test('Should send a transaction and an error event for a faulty generateMetadata() function invokation', async ({ page, }) => { - const testTitle = 'foobarbaz'; + const testTitle = 'should-emit-error'; const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { return ( - transactionEvent.transaction === 'Page.generateMetadata (/generation-functions)' && - (transactionEvent.extra?.route_data as any)?.searchParams?.metadataTitle === testTitle + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/generation-functions?metadataTitle=${testTitle}&shouldThrowInGenerateMetadata=1` ); }); @@ -54,14 +64,23 @@ test('Should send a transaction event for a generateViewport() function invokati const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { return ( - transactionEvent?.transaction === 'Page.generateViewport (/generation-functions)' && - (transactionEvent.extra?.route_data as any)?.searchParams?.viewportThemeColor === testTitle + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/generation-functions?viewportThemeColor=${testTitle}` ); }); await page.goto(`/generation-functions?viewportThemeColor=${testTitle}`); - expect(await transactionPromise).toBeDefined(); + expect((await transactionPromise).spans).toContainEqual( + expect.objectContaining({ + description: 'generateViewport /generation-functions/page', + origin: 'manual', + parent_span_id: expect.any(String), + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }), + ); }); 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 const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { return ( - transactionEvent?.transaction === 'Page.generateViewport (/generation-functions)' && - (transactionEvent.extra?.route_data as any)?.searchParams?.viewportThemeColor === testTitle + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/generation-functions?viewportThemeColor=${testTitle}&shouldThrowInGenerateViewport=1` ); }); @@ -97,8 +116,8 @@ test('Should send a transaction event with correct status for a generateMetadata const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { return ( - transactionEvent?.transaction === 'Page.generateMetadata (/generation-functions/with-redirect)' && - (transactionEvent.extra?.route_data as any)?.searchParams?.metadataTitle === testTitle + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/generation-functions/with-redirect?metadataTitle=${testTitle}` ); }); @@ -114,8 +133,8 @@ test('Should send a transaction event with correct status for a generateMetadata const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { return ( - transactionEvent?.transaction === 'Page.generateMetadata (/generation-functions/with-notfound)' && - (transactionEvent.extra?.route_data as any)?.searchParams?.metadataTitle === testTitle + transactionEvent.contexts?.trace?.data?.['http.target'] === + `/generation-functions/with-notfound?metadataTitle=${testTitle}` ); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts index be6bfab11b84..061c9d3cc5d6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts @@ -3,7 +3,7 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; test('Should send a transaction with a fetch span', async ({ page }) => { const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { - return transactionEvent?.transaction === 'Page Server Component (/request-instrumentation)'; + return transactionEvent?.transaction === 'GET /request-instrumentation'; }); await page.goto(`/request-instrumentation`); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/page.tsx index 4d2763b992b5..41552d578fd4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/page.tsx @@ -6,7 +6,7 @@ export default async function Page() { } export async function generateMetadata() { - (await fetch('http://example.com/')).text(); + (await fetch('http://example.com/', { cache: 'no-store' })).text(); return { title: 'my title', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs index c675d003853a..8448829443d6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs @@ -5,9 +5,15 @@ if (!testEnv) { throw new Error('No test env defined'); } -const config = getPlaywrightConfig({ - startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', - port: 3030, -}); +const config = getPlaywrightConfig( + { + startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', + port: 3030, + }, + { + // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize + workers: '100%', + }, +); export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts index 38325fa6a0e0..3e41c04e2644 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts @@ -1,17 +1,9 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -test('all server component transactions should be attached to the pageload request span', async ({ page }) => { - const pageServerComponentTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'Page Server Component (/pageload-tracing)'; - }); - - const layoutServerComponentTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'Layout Server Component (/pageload-tracing)'; - }); - - const metadataTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'Page.generateMetadata (/pageload-tracing)'; +test('App router transactions should be attached to the pageload request span', async ({ page }) => { + const serverTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent?.transaction === 'GET /pageload-tracing'; }); const pageloadTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { @@ -20,18 +12,13 @@ test('all server component transactions should be attached to the pageload reque await page.goto(`/pageload-tracing`); - const [pageServerComponentTransaction, layoutServerComponentTransaction, metadataTransaction, pageloadTransaction] = - await Promise.all([ - pageServerComponentTransactionPromise, - layoutServerComponentTransactionPromise, - metadataTransactionPromise, - pageloadTransactionPromise, - ]); + const [serverTransaction, pageloadTransaction] = await Promise.all([ + serverTransactionPromise, + pageloadTransactionPromise, + ]); const pageloadTraceId = pageloadTransaction.contexts?.trace?.trace_id; expect(pageloadTraceId).toBeTruthy(); - expect(pageServerComponentTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); - expect(layoutServerComponentTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); - expect(metadataTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); + expect(serverTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ppr-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ppr-error.test.ts index 6fc1a6716127..7c7c0b91eed2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ppr-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ppr-error.test.ts @@ -3,12 +3,12 @@ import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('should not capture React-internal errors for PPR rendering', async ({ page }) => { const pageServerComponentTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'Page Server Component (/ppr-error/[param])'; + return transactionEvent?.transaction === 'GET /ppr-error/[param]'; }); let errorEventReceived = false; - waitForError('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'Page Server Component (/ppr-error/[param])'; + waitForError('nextjs-15', async errorEvent => { + return errorEvent?.transaction === 'Page Server Component (/ppr-error/[param])'; }).then(() => { errorEventReceived = true; }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/suspense-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/suspense-error.test.ts index 6c9a58dab4f3..e158e87ae19f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/suspense-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/suspense-error.test.ts @@ -3,18 +3,19 @@ import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('should not capture serverside suspense errors', async ({ page }) => { const pageServerComponentTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'Page Server Component (/suspense-error)'; + return transactionEvent?.transaction === 'GET /suspense-error'; }); let errorEvent; - waitForError('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'Page Server Component (/suspense-error)'; + waitForError('nextjs-15', async errorEvent => { + return errorEvent?.transaction === 'Page Server Component (/suspense-error)'; }).then(event => { errorEvent = event; }); await page.goto(`/suspense-error`); + // Just to be a little bit more sure await page.waitForTimeout(5000); const pageServerComponentTransaction = await pageServerComponentTransactionPromise; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json index 4e47e84efc8b..8ccad25e6ab4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json @@ -15,7 +15,6 @@ "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev" }, "dependencies": { - "@next/font": "13.0.7", "@sentry/nextjs": "latest || *", "@types/node": "18.11.17", "@types/react": "18.0.26", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.mjs index c675d003853a..8448829443d6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.mjs @@ -5,9 +5,15 @@ if (!testEnv) { throw new Error('No test env defined'); } -const config = getPlaywrightConfig({ - startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', - port: 3030, -}); +const config = getPlaywrightConfig( + { + startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', + port: 3030, + }, + { + // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize + workers: '100%', + }, +); export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts index 41e63f910f79..8645d36c4c8a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts @@ -28,7 +28,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) await page.goto(`/server-component/parameter/${randomRoute}`); await clientPageloadTransactionPromise; - await page.getByText('Page (/server-component/parameter/[parameter])').isVisible(); + await page.getByText('Page (/server-component/[parameter])').isVisible(); const clientNavigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( @@ -39,7 +39,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( - transactionEvent?.transaction === 'Page Server Component (/server-component/parameter/[...parameters])' && + transactionEvent?.transaction === 'GET /server-component/parameter/foo/bar/baz' && (await clientNavigationTransactionPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id ); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts index 287c1e8f8633..3d2f29358d54 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts @@ -1,50 +1,21 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -test('Will capture a connected trace for all server components and generation functions when visiting a page', async ({ +test('Will create a transaction with spans for every server component and metadata generation functions when visiting a page', async ({ page, }) => { - const someConnectedEvent = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'Layout Server Component (/(nested-layout)/nested-layout)' || - transactionEvent?.transaction === 'Layout Server Component (/(nested-layout))' || - transactionEvent?.transaction === 'Page Server Component (/(nested-layout)/nested-layout)' || - transactionEvent?.transaction === 'Page.generateMetadata (/(nested-layout)/nested-layout)' - ); + const serverTransactionEventPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { + return transactionEvent?.transaction === 'GET /nested-layout'; }); - const layout1Transaction = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'Layout Server Component (/(nested-layout)/nested-layout)' && - (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id - ); - }); - - const layout2Transaction = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'Layout Server Component (/(nested-layout))' && - (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id - ); - }); - - const pageTransaction = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'Page Server Component (/(nested-layout)/nested-layout)' && - (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id - ); - }); + await page.goto('/nested-layout'); - const generateMetadataTransaction = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return ( - transactionEvent?.transaction === 'Page.generateMetadata (/(nested-layout)/nested-layout)' && - (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id - ); + const spanDescriptions = (await serverTransactionEventPromise).spans?.map(span => { + return span.description; }); - await page.goto('/nested-layout'); - - expect(await layout1Transaction).toBeDefined(); - expect(await layout2Transaction).toBeDefined(); - expect(await pageTransaction).toBeDefined(); - expect(await generateMetadataTransaction).toBeDefined(); + expect(spanDescriptions).toContainEqual('Layout Server Component (/(nested-layout)/nested-layout)'); + expect(spanDescriptions).toContainEqual('Layout Server Component (/(nested-layout))'); + expect(spanDescriptions).toContainEqual('Page Server Component (/(nested-layout)/nested-layout)'); + expect(spanDescriptions).toContainEqual('Page.generateMetadata (/(nested-layout)/nested-layout)'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts index 6f0413d0cc61..ba232ad558b0 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts @@ -1,15 +1,9 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; -test('Sends a transaction for a server component', async ({ page }) => { - // TODO: Fix that this is flakey on dev server - might be an SDK bug - test.skip(process.env.TEST_ENV === 'production', 'Flakey on dev-server'); - +test('Sends a transaction for a request to app router', async ({ page }) => { const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'function.nextjs' && - transactionEvent?.transaction === 'Page Server Component (/server-component/parameter/[...parameters])' - ); + return transactionEvent?.transaction === 'GET /server-component/parameter/[...parameters]'; }); await page.goto('/server-component/parameter/1337/42'); @@ -18,13 +12,19 @@ test('Sends a transaction for a server component', async ({ page }) => { expect(transactionEvent.contexts?.trace).toEqual({ data: expect.objectContaining({ - 'sentry.op': 'function.nextjs', - 'sentry.origin': 'auto.function.nextjs', + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.otel.http', 'sentry.sample_rate': 1, - 'sentry.source': 'component', + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/server-component/parameter/[...parameters]', + 'http.status_code': 200, + 'http.target': '/server-component/parameter/1337/42', + 'otel.kind': 'SERVER', }), - op: 'function.nextjs', - origin: 'auto.function.nextjs', + op: 'http.server', + origin: 'auto.http.otel.http', span_id: expect.any(String), status: 'ok', trace_id: expect.any(String), @@ -37,22 +37,23 @@ test('Sends a transaction for a server component', async ({ page }) => { headers: expect.any(Object), url: expect.any(String), }, - transaction: 'Page Server Component (/server-component/parameter/[...parameters])', - type: 'transaction', - transaction_info: { - source: 'component', - }, - spans: [], }), ); -}); -test('Should not set an error status on a server component transaction when it redirects', async ({ page }) => { - // TODO: Fix that this is flakey on dev server - might be an SDK bug - test.skip(process.env.TEST_ENV === 'production', 'Flakey on dev-server'); + expect(Object.keys(transactionEvent.request?.headers!).length).toBeGreaterThan(0); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /server-component/parameter/[...parameters]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); +test('Should not set an error status on an app router transaction when it redirects', async ({ page }) => { const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return transactionEvent?.transaction === 'Page Server Component (/server-component/redirect)'; + return transactionEvent?.transaction === 'GET /server-component/redirect'; }); await page.goto('/server-component/redirect'); @@ -62,26 +63,31 @@ test('Should not set an error status on a server component transaction when it r expect(transactionEvent.contexts?.trace?.status).not.toBe('internal_error'); }); -test('Should set a "not_found" status on a server component transaction when notFound() is called', async ({ +test('Should set a "not_found" status on a server component span when notFound() is called and the request span should have status ok', async ({ page, }) => { - // TODO: Fix that this is flakey on dev server - might be an SDK bug - test.skip(process.env.TEST_ENV === 'production', 'Flakey on dev-server'); - const serverComponentTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return transactionEvent?.transaction === 'Page Server Component (/server-component/not-found)'; + return transactionEvent?.transaction === 'GET /server-component/not-found'; }); await page.goto('/server-component/not-found'); const transactionEvent = await serverComponentTransactionPromise; - expect(transactionEvent.contexts?.trace?.status).toBe('not_found'); + // Transaction should have status ok, because the http status is ok, but the server component span should be not_found + expect(transactionEvent.contexts?.trace?.status).toBe('ok'); + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + description: 'Page Server Component (/server-component/not-found)', + op: 'function.nextjs', + status: 'not_found', + }), + ); }); -test('Should capture an error and transaction with correct status for a faulty server component', async ({ page }) => { +test('Should capture an error and transaction for a app router page', async ({ page }) => { const transactionEventPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { - return transactionEvent?.transaction === 'Page Server Component (/server-component/faulty)'; + return transactionEvent?.transaction === 'GET /server-component/faulty'; }); const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { @@ -93,10 +99,19 @@ test('Should capture an error and transaction with correct status for a faulty s const transactionEvent = await transactionEventPromise; const errorEvent = await errorEventPromise; - expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); - + // Error event should have the right transaction name expect(errorEvent.transaction).toBe(`Page Server Component (/server-component/faulty)`); + // Transaction should have status ok, because the http status is ok, but the server component span should be internal_error + expect(transactionEvent.contexts?.trace?.status).toBe('ok'); + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + description: 'Page Server Component (/server-component/faulty)', + op: 'function.nextjs', + status: 'internal_error', + }), + ); + expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); expect(transactionEvent.tags?.['my-isolated-tag']).toBe(true); diff --git a/dev-packages/test-utils/src/playwright-config.ts b/dev-packages/test-utils/src/playwright-config.ts index 4f2ea54bc3b4..33de29f5a7fc 100644 --- a/dev-packages/test-utils/src/playwright-config.ts +++ b/dev-packages/test-utils/src/playwright-config.ts @@ -37,7 +37,7 @@ export function getPlaywrightConfig( /* In dev mode some apps are flaky, so we allow retry there... */ retries: testEnv === 'development' ? 3 : 0, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'list', + reporter: process.env.CI ? 'line' : 'list', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 74c448b9e195..29ef2fba8ccc 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -69,6 +69,7 @@ }, "dependencies": { "@opentelemetry/instrumentation-http": "0.52.1", + "@opentelemetry/semantic-conventions": "^1.25.1", "@rollup/plugin-commonjs": "26.0.1", "@sentry/core": "8.15.0", "@sentry/node": "8.15.0", diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 0c76821afa28..5944b520f6ea 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -2,10 +2,14 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, SPAN_STATUS_ERROR, SPAN_STATUS_OK, + Scope, captureException, getActiveSpan, + getCapturedScopesOnSpan, getClient, + getRootSpan, handleCallbackErrors, + setCapturedScopesOnSpan, startSpanManual, withIsolationScope, withScope, @@ -16,11 +20,7 @@ import { propagationContextFromHeaders, uuid4, winterCGHeadersToDict } from '@se import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import type { GenerationFunctionContext } from '../common/types'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; -import { - commonObjectToIsolationScope, - commonObjectToPropagationContext, - escapeNextjsTracing, -} from './utils/tracingUtils'; +import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; /** * Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation. @@ -34,87 +34,98 @@ export function wrapGenerationFunctionWithSentry a return new Proxy(generationFunction, { apply: (originalFunction, thisArg, args) => { const requestTraceId = getActiveSpan()?.spanContext().traceId; - return escapeNextjsTracing(() => { - let headers: WebFetchHeaders | undefined = undefined; - // We try-catch here just in case anything goes wrong with the async storage here goes wrong since it is Next.js internal API - try { - headers = requestAsyncStorage?.getStore()?.headers; - } catch (e) { - /** empty */ - } + let headers: WebFetchHeaders | undefined = undefined; + // We try-catch here just in case anything goes wrong with the async storage here goes wrong since it is Next.js internal API + try { + headers = requestAsyncStorage?.getStore()?.headers; + } catch (e) { + /** empty */ + } - let data: Record | undefined = undefined; - if (getClient()?.getOptions().sendDefaultPii) { - const props: unknown = args[0]; - const params = props && typeof props === 'object' && 'params' in props ? props.params : undefined; - const searchParams = - props && typeof props === 'object' && 'searchParams' in props ? props.searchParams : undefined; - data = { params, searchParams }; - } + const isolationScope = commonObjectToIsolationScope(headers); - const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; + const activeSpan = getActiveSpan(); + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + const { scope } = getCapturedScopesOnSpan(rootSpan); + setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope); - const isolationScope = commonObjectToIsolationScope(headers); + // We mark the root span as an app router span so we can allow-list it in our span processor that would normally filter out all Next.js transactions/spans + rootSpan.setAttribute('sentry.rsc', true); + } - return withIsolationScope(isolationScope, () => { - return withScope(scope => { - scope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`); + let data: Record | undefined = undefined; + if (getClient()?.getOptions().sendDefaultPii) { + const props: unknown = args[0]; + const params = props && typeof props === 'object' && 'params' in props ? props.params : undefined; + const searchParams = + props && typeof props === 'object' && 'searchParams' in props ? props.searchParams : undefined; + data = { params, searchParams }; + } - isolationScope.setSDKProcessingMetadata({ - request: { - headers: headersDict, - }, - }); + const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; - const propagationContext = commonObjectToPropagationContext( - headers, - headersDict?.['sentry-trace'] - ? propagationContextFromHeaders(headersDict['sentry-trace'], headersDict['baggage']) - : { - traceId: requestTraceId || uuid4(), - spanId: uuid4().substring(16), - }, - ); + return withIsolationScope(isolationScope, () => { + return withScope(scope => { + scope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`); - scope.setExtra('route_data', data); - scope.setPropagationContext(propagationContext); + isolationScope.setSDKProcessingMetadata({ + request: { + headers: headersDict, + }, + }); - return startSpanManual( - { - op: 'function.nextjs', - name: `${componentType}.${generationFunctionIdentifier} (${componentRoute})`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', + const propagationContext = commonObjectToPropagationContext( + headers, + headersDict?.['sentry-trace'] + ? propagationContextFromHeaders(headersDict['sentry-trace'], headersDict['baggage']) + : { + traceId: requestTraceId || uuid4(), + spanId: uuid4().substring(16), }, + ); + scope.setPropagationContext(propagationContext); + + scope.setExtra('route_data', data); + + return startSpanManual( + { + op: 'function.nextjs', + name: `${componentType}.${generationFunctionIdentifier} (${componentRoute})`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', }, - span => { - return handleCallbackErrors( - () => originalFunction.apply(thisArg, args), - err => { - if (isNotFoundNavigationError(err)) { - // We don't want to report "not-found"s - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - } else if (isRedirectNavigationError(err)) { - // We don't want to report redirects - span.setStatus({ code: SPAN_STATUS_OK }); - } else { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(err, { - mechanism: { - handled: false, - }, - }); - } - }, - () => { - span.end(); - }, - ); - }, - ); - }); + }, + span => { + return handleCallbackErrors( + () => originalFunction.apply(thisArg, args), + err => { + // When you read this code you might think: "Wait a minute, shouldn't we set the status on the root span too?" + // The answer is: "No." - The status of the root span is determined by whatever status code Next.js decides to put on the response. + if (isNotFoundNavigationError(err)) { + // We don't want to report "not-found"s + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + getRootSpan(span).setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + } else if (isRedirectNavigationError(err)) { + // We don't want to report redirects + span.setStatus({ code: SPAN_STATUS_OK }); + } else { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + getRootSpan(span).setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(err, { + mechanism: { + handled: false, + }, + }); + } + }, + () => { + span.end(); + }, + ); + }, + ); }); }); }, diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 0d1e224bdf47..e8d734a90ff3 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -2,9 +2,13 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, SPAN_STATUS_ERROR, SPAN_STATUS_OK, + Scope, captureException, getActiveSpan, + getCapturedScopesOnSpan, + getRootSpan, handleCallbackErrors, + setCapturedScopesOnSpan, startSpanManual, withIsolationScope, withScope, @@ -15,11 +19,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import type { ServerComponentContext } from '../common/types'; import { flushSafelyWithTimeout } from './utils/responseEnd'; -import { - commonObjectToIsolationScope, - commonObjectToPropagationContext, - escapeNextjsTracing, -} from './utils/tracingUtils'; +import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; import { vercelWaitUntil } from './utils/vercelWaitUntil'; /** @@ -37,21 +37,31 @@ export function wrapServerComponentWithSentry any> return new Proxy(appDirComponent, { apply: (originalFunction, thisArg, args) => { const requestTraceId = getActiveSpan()?.spanContext().traceId; - return escapeNextjsTracing(() => { - const isolationScope = commonObjectToIsolationScope(context.headers); + const isolationScope = commonObjectToIsolationScope(context.headers); - const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined; + const activeSpan = getActiveSpan(); + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + const { scope } = getCapturedScopesOnSpan(rootSpan); + setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope); - isolationScope.setSDKProcessingMetadata({ - request: { - headers: headersDict, - }, - }); + // We mark the root span as an app router span so we can allow-list it in our span processor that would normally filter out all Next.js transactions/spans + rootSpan.setAttribute('sentry.rsc', true); + } - return withIsolationScope(isolationScope, () => { - return withScope(scope => { - scope.setTransactionName(`${componentType} Server Component (${componentRoute})`); + const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined; + + isolationScope.setSDKProcessingMetadata({ + request: { + headers: headersDict, + }, + }); + return withIsolationScope(isolationScope, () => { + return withScope(scope => { + scope.setTransactionName(`${componentType} Server Component (${componentRoute})`); + + if (process.env.NEXT_RUNTIME === 'edge') { const propagationContext = commonObjectToPropagationContext( context.headers, headersDict?.['sentry-trace'] @@ -63,43 +73,45 @@ export function wrapServerComponentWithSentry any> ); scope.setPropagationContext(propagationContext); - return startSpanManual( - { - op: 'function.nextjs', - name: `${componentType} Server Component (${componentRoute})`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - }, - }, - span => { - return handleCallbackErrors( - () => originalFunction.apply(thisArg, args), - error => { - if (isNotFoundNavigationError(error)) { - // We don't want to report "not-found"s - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - } else if (isRedirectNavigationError(error)) { - // We don't want to report redirects - span.setStatus({ code: SPAN_STATUS_OK }); - } else { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - }, - }); - } - }, - () => { - span.end(); - vercelWaitUntil(flushSafelyWithTimeout()); - }, - ); + } + + return startSpanManual( + { + op: 'function.nextjs', + name: `${componentType} Server Component (${componentRoute})`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', }, - ); - }); + }, + span => { + return handleCallbackErrors( + () => originalFunction.apply(thisArg, args), + error => { + // When you read this code you might think: "Wait a minute, shouldn't we set the status on the root span too?" + // The answer is: "No." - The status of the root span is determined by whatever status code Next.js decides to put on the response. + if (isNotFoundNavigationError(error)) { + // We don't want to report "not-found"s + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + } else if (isRedirectNavigationError(error)) { + // We don't want to report redirects + span.setStatus({ code: SPAN_STATUS_OK }); + } else { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + }, + }); + } + }, + () => { + span.end(); + vercelWaitUntil(flushSafelyWithTimeout()); + }, + ); + }, + ); }); }); }, diff --git a/packages/nextjs/src/server/httpIntegration.ts b/packages/nextjs/src/server/httpIntegration.ts deleted file mode 100644 index 49f7318826c9..000000000000 --- a/packages/nextjs/src/server/httpIntegration.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; -import { httpIntegration as originalHttpIntegration } from '@sentry/node'; -import type { IntegrationFn } from '@sentry/types'; - -/** - * Next.js handles incoming requests itself, - * but it does not handle outgoing requests. - * Today, it is not possible to use the HttpInstrumentation for only outgoing requests - - * until https://github.com/open-telemetry/opentelemetry-js/pull/4643 is merged & released. - * So in the meanwhile, we extend the base HttpInstrumentation to not wrap incoming requests. - */ -class CustomNextjsHttpIntegration extends HttpInstrumentation { - // Instead of the default behavior, we just don't do any wrapping for incoming requests - protected _getPatchIncomingRequestFunction(_component: 'http' | 'https') { - return ( - original: (event: string, ...args: unknown[]) => boolean, - ): ((this: unknown, event: string, ...args: unknown[]) => boolean) => { - return function incomingRequest(this: unknown, event: string, ...args: unknown[]): boolean { - return original.apply(this, [event, ...args]); - }; - }; - } -} - -type HttpOptions = Parameters[0]; - -/** - * The http integration instruments Node's internal http and https modules. - * It creates breadcrumbs and spans for outgoing HTTP requests which will be attached to the currently active span. - */ -export const httpIntegration = ((options: HttpOptions = {}) => { - return originalHttpIntegration({ - ...options, - _instrumentation: CustomNextjsHttpIntegration, - }); -}) satisfies IntegrationFn; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 741c4092c61b..1132a6e1eed2 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -1,8 +1,17 @@ -import { applySdkMetadata, getClient, getGlobalScope } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + applySdkMetadata, + getClient, + getGlobalScope, + getRootSpan, + spanToJSON, +} from '@sentry/core'; import { getDefaultIntegrations, init as nodeInit } from '@sentry/node'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { GLOBAL_OBJ, logger } from '@sentry/utils'; +import { SEMATTRS_HTTP_METHOD, SEMATTRS_HTTP_ROUTE, SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; +import type { EventProcessor } from '@sentry/types'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; @@ -10,10 +19,6 @@ import { isBuild } from '../common/utils/isBuild'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; export * from '@sentry/node'; -import type { EventProcessor } from '@sentry/types'; -import { httpIntegration } from './httpIntegration'; - -export { httpIntegration }; export { captureUnderscoreErrorException } from '../common/_error'; @@ -86,14 +91,7 @@ export function init(options: NodeOptions): NodeClient | undefined { return; } - const customDefaultIntegrations = [ - ...getDefaultIntegrations(options).filter( - integration => - // Next.js comes with its own Http instrumentation for OTel which would lead to double spans for route handler requests - integration.name !== 'Http', - ), - httpIntegration(), - ]; + const customDefaultIntegrations = getDefaultIntegrations(options); // Turn off Next.js' own fetch instrumentation // https://github.com/lforst/nextjs-fork/blob/1994fd186defda77ad971c36dc3163db263c993f/packages/next/src/server/lib/patch-fetch.ts#L245 @@ -128,8 +126,14 @@ export function init(options: NodeOptions): NodeClient | undefined { applySdkMetadata(opts, 'nextjs', ['nextjs', 'node']); const client = nodeInit(opts); - client?.on('beforeSampling', ({ spanAttributes, spanName, parentSampled, parentContext }, samplingDecision) => { + // We allowlist the "BaseServer.handleRequest" span, since that one is responsible for App Router requests, which are actually useful for us. + // HOWEVER, that span is not only responsible for App Router requests, which is why we additionally filter for certain transactions in an + // event processor further below. + if (spanAttributes['next.span_type'] === 'BaseServer.handleRequest') { + return; + } + // If we encounter a span emitted by Next.js, we do not want to sample it // The reason for this is that the data quality of the spans varies, it is different per version of Next, // and we need to keep our manual instrumentation around for the edge runtime anyhow. @@ -140,6 +144,46 @@ export function init(options: NodeOptions): NodeClient | undefined { ) { samplingDecision.decision = false; } + + // There are situations where the Next.js Node.js server forwards requests for the Edge Runtime server (e.g. in + // middleware) and this causes spans for Sentry ingest requests to be created. These are not exempt from our tracing + // because we didn't get the chance to do `suppressTracing`, since this happens outside of userland. + // We need to drop these spans. + if ( + typeof spanAttributes[SEMATTRS_HTTP_TARGET] === 'string' && + spanAttributes[SEMATTRS_HTTP_TARGET].includes('sentry_key') && + spanAttributes[SEMATTRS_HTTP_TARGET].includes('sentry_client') + ) { + samplingDecision.decision = false; + } + }); + + client?.on('spanStart', span => { + const spanAttributes = spanToJSON(span).data; + + // What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted + // by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute. + if (spanAttributes?.['next.route']) { + const rootSpan = getRootSpan(span); + const rootSpanAttributes = spanToJSON(rootSpan).data; + + // Only hoist the http.route attribute if the transaction doesn't already have it + if (rootSpanAttributes?.[SEMATTRS_HTTP_METHOD] && !rootSpanAttributes?.[SEMATTRS_HTTP_ROUTE]) { + rootSpan.setAttribute(SEMATTRS_HTTP_ROUTE, spanAttributes['next.route']); + } + } + + // We want to skip span data inference for any spans generated by Next.js. Reason being that Next.js emits spans + // with patterns (e.g. http.server spans) that will produce confusing data. + if (spanAttributes?.['next.span_type'] !== undefined) { + span.setAttribute('sentry.skip_span_data_inference', true); + } + + // We want to rename these spans because they look like "GET /path/to/route" and we already emit spans that look + // like this with our own http instrumentation. + if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest') { + span.updateName('next server handler'); // This is all lowercase because the spans that Next.js emits by itself generally look like this. + } }); getGlobalScope().addEventProcessor( @@ -153,6 +197,15 @@ export function init(options: NodeOptions): NodeClient | undefined { return null; } + // We only want to use our HTTP integration/instrumentation for app router requests, which are marked with the `sentry.rsc` attribute. + if ( + (event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.http.otel.http' || + event.contexts?.trace?.data?.['next.span_type'] === 'BaseServer.handleRequest') && + event.contexts?.trace?.data?.['sentry.rsc'] !== true + ) { + return null; + } + // Filter out transactions for requests to the tunnel route if ( globalWithInjectedValues.__sentryRewritesTunnelPath__ &&