From be781081aeb5cdb3aa66d531fb38c6ac902f2df1 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 23 May 2024 08:37:49 +0000 Subject: [PATCH] feat(nextjs): Trace pageloads in App Router --- .github/workflows/build.yml | 1 + .../test-applications/nextjs-15/.gitignore | 45 +++++++++++ .../test-applications/nextjs-15/.npmrc | 2 + .../nextjs-15/app/layout.tsx | 7 ++ .../nextjs-15/app/pageload-tracing/layout.tsx | 8 ++ .../nextjs-15/app/pageload-tracing/page.tsx | 14 ++++ .../test-applications/nextjs-15/globals.d.ts | 4 + .../nextjs-15/instrumentation.ts | 9 +++ .../test-applications/nextjs-15/next-env.d.ts | 5 ++ .../nextjs-15/next.config.js | 8 ++ .../test-applications/nextjs-15/package.json | 46 +++++++++++ .../nextjs-15/playwright.config.ts | 80 +++++++++++++++++++ .../nextjs-15/sentry.client.config.ts | 9 +++ .../nextjs-15/sentry.edge.config.ts | 13 +++ .../nextjs-15/sentry.server.config.ts | 13 +++ .../nextjs-15/start-event-proxy.mjs | 6 ++ .../nextjs-15/tests/pageload-tracing.test.ts | 37 +++++++++ .../test-applications/nextjs-15/tsconfig.json | 25 ++++++ .../wrapGenerationFunctionWithSentry.ts | 25 ++++-- .../common/wrapServerComponentWithSentry.ts | 29 ++++--- packages/nextjs/src/config/types.ts | 1 + .../nextjs/src/config/withSentryConfig.ts | 42 +++++++++- 22 files changed, 409 insertions(+), 20 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/sentry.client.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/sentry.edge.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bcbef8e06a8e..ee592f6b7eb8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1007,6 +1007,7 @@ jobs: 'node-express-esm-without-loader', 'nextjs-app-dir', 'nextjs-14', + 'nextjs-15', 'react-create-hash-router', 'react-router-6-use-routes', 'react-router-5', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore new file mode 100644 index 000000000000..e799cc33c4e7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +!*.d.ts + +# Sentry +.sentryclirc + +.vscode + +test-results diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-15/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/layout.tsx new file mode 100644 index 000000000000..1f0cbe478f88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/layout.tsx @@ -0,0 +1,8 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default async function Layout({ children }: PropsWithChildren) { + await new Promise(resolve => setTimeout(resolve, 500)); + return <>{children}; +} 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 new file mode 100644 index 000000000000..4d2763b992b5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/pageload-tracing/page.tsx @@ -0,0 +1,14 @@ +export const dynamic = 'force-dynamic'; + +export default async function Page() { + await new Promise(resolve => setTimeout(resolve, 1000)); + return

I am page 2

; +} + +export async function generateMetadata() { + (await fetch('http://example.com/')).text(); + + return { + title: 'my title', + }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/globals.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/globals.d.ts new file mode 100644 index 000000000000..109dbcd55648 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/globals.d.ts @@ -0,0 +1,4 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts new file mode 100644 index 000000000000..7b89a972e157 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts @@ -0,0 +1,9 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts new file mode 100644 index 000000000000..4f11a03dc6cc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js new file mode 100644 index 000000000000..1098c2ce5a4f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js @@ -0,0 +1,8 @@ +const { withSentryConfig } = require('@sentry/nextjs'); + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +module.exports = withSentryConfig(nextConfig, { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json new file mode 100644 index 000000000000..7c3f4a9f8fb1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -0,0 +1,46 @@ +{ + "name": "create-next-app", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:build-canary": "pnpm install && pnpm add next@canary && npx playwright install && pnpm build", + "test:build-latest": "pnpm install && pnpm add next@latest && npx playwright install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@playwright/test": "^1.27.1", + "@sentry/nextjs": "latest || *", + "@types/node": "18.11.17", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.9", + "next": "14.3.0-canary.73", + "react": "beta", + "react-dom": "beta", + "typescript": "4.9.5", + "wait-port": "1.0.4" + }, + "devDependencies": { + "@sentry-internal/event-proxy-server": "link:../../../event-proxy-server", + "@sentry-internal/feedback": "latest || *", + "@sentry-internal/replay-canvas": "latest || *", + "@sentry-internal/browser-utils": "latest || *", + "@sentry/browser": "latest || *", + "@sentry/core": "latest || *", + "@sentry/nextjs": "latest || *", + "@sentry/node": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@sentry/react": "latest || *", + "@sentry-internal/replay": "latest || *", + "@sentry/types": "latest || *", + "@sentry/utils": "latest || *", + "@sentry/vercel-edge": "latest || *" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.ts new file mode 100644 index 000000000000..0709f27158b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.ts @@ -0,0 +1,80 @@ +import os from 'os'; +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +// Fix urls not resolving to localhost on Node v17+ +// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575 +import { setDefaultResultOrder } from 'dns'; +setDefaultResultOrder('ipv4first'); + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const nextPort = 3030; +const eventProxyPort = 3031; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 30_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 10000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Defaults to half the number of CPUs. The tests are not really CPU-bound but rather I/O-bound with all the polling we do so we increase the concurrency to the CPU count. */ + workers: os.cpus().length, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* `next dev` is incredibly buggy with the app dir */ + retries: testEnv === 'development' ? 3 : 0, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: '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). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: `http://localhost:${nextPort}`, + trace: 'retain-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'node start-event-proxy.mjs', + port: eventProxyPort, + }, + { + command: + testEnv === 'development' + ? `pnpm wait-port ${eventProxyPort} && pnpm next dev -p ${nextPort}` + : `pnpm wait-port ${eventProxyPort} && pnpm next start -p ${nextPort}`, + port: nextPort, + stdout: 'pipe', + stderr: 'pipe', + }, + ], +}; + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.client.config.ts new file mode 100644 index 000000000000..85bd765c9c44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.client.config.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.edge.config.ts new file mode 100644 index 000000000000..067d2ead0b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.edge.config.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts new file mode 100644 index 000000000000..067d2ead0b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15/start-event-proxy.mjs new file mode 100644 index 000000000000..56744b35c7e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-15', +}); 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 new file mode 100644 index 000000000000..7893633d3b48 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/pageload-tracing.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/event-proxy-server'; + +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)'; + }); + + const pageloadTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent?.transaction === '/pageload-tracing'; + }); + + await page.goto(`/pageload-tracing`); + + const [pageServerComponentTransaction, layoutServerComponentTransaction, metadataTransaction, pageloadTransaction] = + await Promise.all([ + pageServerComponentTransactionPromise, + layoutServerComponentTransactionPromise, + metadataTransactionPromise, + 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); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json new file mode 100644 index 000000000000..ef9e351d7a7b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ], + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js", ".next/types/**/*.ts"], + "exclude": ["node_modules", "playwright.config.ts"] +} diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index f3998b693b38..14d14045142c 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -3,6 +3,7 @@ import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, captureException, + getActiveSpan, getClient, handleCallbackErrors, startSpanManual, @@ -10,9 +11,11 @@ import { withScope, } from '@sentry/core'; import type { WebFetchHeaders } from '@sentry/types'; -import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; +import { propagationContextFromHeaders, uuid4, winterCGHeadersToDict } from '@sentry/utils'; +import { context as otelContext } from '@opentelemetry/api'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { EXPERIMENTAL_SENTRY_REQUEST_SPAN_ID_SUGGESTION_CONTEXT_KEY } from '@sentry/opentelemetry'; import type { GenerationFunctionContext } from '../common/types'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import { @@ -32,6 +35,7 @@ export function wrapGenerationFunctionWithSentry a const { requestAsyncStorage, componentRoute, componentType, generationFunctionIdentifier } = context; 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 @@ -50,23 +54,30 @@ export function wrapGenerationFunctionWithSentry a data = { params, searchParams }; } - const incomingPropagationContext = propagationContextFromHeaders( - headers?.get('sentry-trace') ?? undefined, - headers?.get('baggage'), - ); + const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; const isolationScope = commonObjectToIsolationScope(headers); - const propagationContext = commonObjectToPropagationContext(headers, incomingPropagationContext); return withIsolationScope(isolationScope, () => { return withScope(scope => { scope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`); + isolationScope.setSDKProcessingMetadata({ request: { - headers: headers ? winterCGHeadersToDict(headers) : undefined, + headers: headersDict, }, }); + const propagationContext = commonObjectToPropagationContext( + headers, + headersDict?.['sentry-trace'] + ? propagationContextFromHeaders(headersDict['sentry-trace'], headersDict['baggage']) + : { + traceId: requestTraceId || uuid4(), + spanId: uuid4().substring(16), + }, + ); + scope.setExtra('route_data', data); scope.setPropagationContext(propagationContext); diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 1234ea448a3d..4f653128ec9a 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -3,14 +3,17 @@ import { SPAN_STATUS_ERROR, SPAN_STATUS_OK, captureException, + getActiveSpan, handleCallbackErrors, startSpanManual, withIsolationScope, withScope, } from '@sentry/core'; -import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; +import { propagationContextFromHeaders, uuid4, winterCGHeadersToDict } from '@sentry/utils'; +import { context as otelContext } from '@opentelemetry/api'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { EXPERIMENTAL_SENTRY_REQUEST_SPAN_ID_SUGGESTION_CONTEXT_KEY } from '@sentry/opentelemetry'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import type { ServerComponentContext } from '../common/types'; import { flushQueue } from './utils/responseEnd'; @@ -34,30 +37,32 @@ export function wrapServerComponentWithSentry any> // hook. 🤯 return new Proxy(appDirComponent, { apply: (originalFunction, thisArg, args) => { + const requestTraceId = getActiveSpan()?.spanContext().traceId; return escapeNextjsTracing(() => { const isolationScope = commonObjectToIsolationScope(context.headers); - const completeHeadersDict: Record = context.headers - ? winterCGHeadersToDict(context.headers) - : {}; + const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined; isolationScope.setSDKProcessingMetadata({ request: { - headers: completeHeadersDict, + headers: headersDict, }, }); - const incomingPropagationContext = propagationContextFromHeaders( - completeHeadersDict['sentry-trace'], - completeHeadersDict['baggage'], - ); - - const propagationContext = commonObjectToPropagationContext(context.headers, incomingPropagationContext); - return withIsolationScope(isolationScope, () => { return withScope(scope => { scope.setTransactionName(`${componentType} Server Component (${componentRoute})`); + const propagationContext = commonObjectToPropagationContext( + context.headers, + headersDict?.['sentry-trace'] + ? propagationContextFromHeaders(headersDict['sentry-trace'], headersDict['baggage']) + : { + traceId: requestTraceId || uuid4(), + spanId: uuid4().substring(16), + }, + ); + scope.setPropagationContext(propagationContext); return startSpanManual( { diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index f748c5beb115..57601279997c 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -46,6 +46,7 @@ export type NextConfigObject = { // Next.js experimental options experimental?: { instrumentationHook?: boolean; + clientTraceMetadata?: string[]; }; }; diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index f9c815fe6efb..72b44e16199a 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -1,5 +1,7 @@ -import { isThenable } from '@sentry/utils'; +import { isThenable, parseSemver } from '@sentry/utils'; +import * as fs from 'fs'; +import { sync as resolveSync } from 'resolve'; import type { ExportedNextConfig as NextConfig, NextConfigFunction, @@ -82,6 +84,19 @@ function getFinalConfigObject( ...incomingUserNextConfigObject.experimental, }; + // Add the `clientTraceMetadata` experimental option based on Next.js version. The option got introduced in Next.js version 15.0.0 (actually 14.3.0-canary.64). + // Adding the option on lower versions will cause Next.js to print nasty warnings we wouldn't confront our users with. + const nextJsVersion = getNextjsVersion(); + if (nextJsVersion) { + const { major, minor } = parseSemver(nextJsVersion); + if (major && minor && (major >= 15 || (major === 14 && minor >= 3))) { + incomingUserNextConfigObject.experimental = { + clientTraceMetadata: ['baggage', 'sentry-trace'], + ...incomingUserNextConfigObject.experimental, + }; + } + } + return { ...incomingUserNextConfigObject, webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions), @@ -163,3 +178,28 @@ function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: s } }; } + +function getNextjsVersion(): string | undefined { + const nextjsPackageJsonPath = resolveNextjsPackageJson(); + if (nextjsPackageJsonPath) { + try { + const nextjsPackageJson: { version: string } = JSON.parse( + fs.readFileSync(nextjsPackageJsonPath, { encoding: 'utf-8' }), + ); + return nextjsPackageJson.version; + } catch { + // noop + } + } + + return undefined; +} + +function resolveNextjsPackageJson(): string | undefined { + try { + return resolveSync('next/package.json', { basedir: process.cwd() }); + } catch { + // Should not happen in theory + return undefined; + } +}