diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5f09f9587c00..3750c8adf14a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -864,6 +864,7 @@ jobs: 'create-remix-app-v2', 'debug-id-sourcemaps', 'nextjs-app-dir', + 'nextjs-14', 'react-create-hash-router', 'react-router-6-use-routes', 'standard-frontend-react', diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index ed05e9bfd1af..9801407515cd 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -73,6 +73,12 @@ jobs: - test-application: 'nextjs-app-dir' build-command: 'test:build-latest' label: 'nextjs-app-dir (latest)' + - test-application: 'nextjs-14' + build-command: 'test:build-canary' + label: 'nextjs-14 (canary)' + - test-application: 'nextjs-14' + build-command: 'test:build-latest' + label: 'nextjs-14 (latest)' - test-application: 'react-create-hash-router' build-command: 'test:build-canary' label: 'react-create-hash-router (canary)' diff --git a/packages/e2e-tests/test-applications/nextjs-14/.gitignore b/packages/e2e-tests/test-applications/nextjs-14/.gitignore new file mode 100644 index 000000000000..e799cc33c4e7 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/.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/packages/e2e-tests/test-applications/nextjs-14/.npmrc b/packages/e2e-tests/test-applications/nextjs-14/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/.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/packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx b/packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx new file mode 100644 index 000000000000..5ae73102057d --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/page.tsx @@ -0,0 +1,33 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Hello World!

; +} + +export async function generateMetadata({ + searchParams, +}: { + searchParams: { [key: string]: string | string[] | undefined }; +}) { + if (searchParams['shouldThrowInGenerateMetadata']) { + throw new Error('generateMetadata Error'); + } + + return { + title: searchParams['metadataTitle'] ?? 'not set', + }; +} + +export function generateViewport({ + searchParams, +}: { + searchParams: { [key: string]: string | undefined }; +}) { + if (searchParams['shouldThrowInGenerateViewport']) { + throw new Error('generateViewport Error'); + } + + return { + themeColor: searchParams['viewportThemeColor'] ?? 'black', + }; +} diff --git a/packages/e2e-tests/test-applications/nextjs-14/app/layout.tsx b/packages/e2e-tests/test-applications/nextjs-14/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/packages/e2e-tests/test-applications/nextjs-14/event-proxy-server.ts b/packages/e2e-tests/test-applications/nextjs-14/event-proxy-server.ts new file mode 100644 index 000000000000..9dee679c71e4 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/event-proxy-server.ts @@ -0,0 +1,253 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; +import type { AddressInfo } from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import * as util from 'util'; +import * as zlib from 'zlib'; +import type { Envelope, EnvelopeItem, Event } from '@sentry/types'; +import { parseEnvelope } from '@sentry/utils'; + +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + +interface EventProxyServerOptions { + /** Port to start the event proxy server at. */ + port: number; + /** The name for the proxy server used for referencing it with listener functions */ + proxyServerName: string; +} + +interface SentryRequestCallbackData { + envelope: Envelope; + rawProxyRequestBody: string; + rawSentryResponseBody: string; + sentryResponseStatusCode?: number; +} + +/** + * Starts an event proxy server that will proxy events to sentry when the `tunnel` option is used. Point the `tunnel` + * option to this server (like this `tunnel: http://localhost:${port option}/`). + */ +export async function startEventProxyServer(options: EventProxyServerOptions): Promise { + const eventCallbackListeners: Set<(data: string) => void> = new Set(); + + const proxyServer = http.createServer((proxyRequest, proxyResponse) => { + const proxyRequestChunks: Uint8Array[] = []; + + proxyRequest.addListener('data', (chunk: Buffer) => { + proxyRequestChunks.push(chunk); + }); + + proxyRequest.addListener('error', err => { + throw err; + }); + + proxyRequest.addListener('end', () => { + const proxyRequestBody = + proxyRequest.headers['content-encoding'] === 'gzip' + ? zlib.gunzipSync(Buffer.concat(proxyRequestChunks)).toString() + : Buffer.concat(proxyRequestChunks).toString(); + + let envelopeHeader = JSON.parse(proxyRequestBody.split('\n')[0]); + + if (!envelopeHeader.dsn) { + throw new Error('[event-proxy-server] No dsn on envelope header. Please set tunnel option.'); + } + + const { origin, pathname, host } = new URL(envelopeHeader.dsn); + + const projectId = pathname.substring(1); + const sentryIngestUrl = `${origin}/api/${projectId}/envelope/`; + + proxyRequest.headers.host = host; + + const sentryResponseChunks: Uint8Array[] = []; + + const sentryRequest = https.request( + sentryIngestUrl, + { headers: proxyRequest.headers, method: proxyRequest.method }, + sentryResponse => { + sentryResponse.addListener('data', (chunk: Buffer) => { + proxyResponse.write(chunk, 'binary'); + sentryResponseChunks.push(chunk); + }); + + sentryResponse.addListener('end', () => { + eventCallbackListeners.forEach(listener => { + const rawSentryResponseBody = Buffer.concat(sentryResponseChunks).toString(); + + const data: SentryRequestCallbackData = { + envelope: parseEnvelope(proxyRequestBody, new TextEncoder(), new TextDecoder()), + rawProxyRequestBody: proxyRequestBody, + rawSentryResponseBody, + sentryResponseStatusCode: sentryResponse.statusCode, + }; + + listener(Buffer.from(JSON.stringify(data)).toString('base64')); + }); + proxyResponse.end(); + }); + + sentryResponse.addListener('error', err => { + throw err; + }); + + proxyResponse.writeHead(sentryResponse.statusCode || 500, sentryResponse.headers); + }, + ); + + sentryRequest.write(Buffer.concat(proxyRequestChunks), 'binary'); + sentryRequest.end(); + }); + }); + + const proxyServerStartupPromise = new Promise(resolve => { + proxyServer.listen(options.port, () => { + resolve(); + }); + }); + + const eventCallbackServer = http.createServer((eventCallbackRequest, eventCallbackResponse) => { + eventCallbackResponse.statusCode = 200; + eventCallbackResponse.setHeader('connection', 'keep-alive'); + + const callbackListener = (data: string): void => { + eventCallbackResponse.write(data.concat('\n'), 'utf8'); + }; + + eventCallbackListeners.add(callbackListener); + + eventCallbackRequest.on('close', () => { + eventCallbackListeners.delete(callbackListener); + }); + + eventCallbackRequest.on('error', () => { + eventCallbackListeners.delete(callbackListener); + }); + }); + + const eventCallbackServerStartupPromise = new Promise(resolve => { + eventCallbackServer.listen(0, () => { + const port = String((eventCallbackServer.address() as AddressInfo).port); + void registerCallbackServerPort(options.proxyServerName, port).then(resolve); + }); + }); + + await eventCallbackServerStartupPromise; + await proxyServerStartupPromise; + return; +} + +export async function waitForRequest( + proxyServerName: string, + callback: (eventData: SentryRequestCallbackData) => Promise | boolean, +): Promise { + const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName); + + return new Promise((resolve, reject) => { + const request = http.request(`http://localhost:${eventCallbackServerPort}/`, {}, response => { + let eventContents = ''; + + response.on('error', err => { + reject(err); + }); + + response.on('data', (chunk: Buffer) => { + const chunkString = chunk.toString('utf8'); + chunkString.split('').forEach(char => { + if (char === '\n') { + const eventCallbackData: SentryRequestCallbackData = JSON.parse( + Buffer.from(eventContents, 'base64').toString('utf8'), + ); + const callbackResult = callback(eventCallbackData); + if (typeof callbackResult !== 'boolean') { + callbackResult.then( + match => { + if (match) { + response.destroy(); + resolve(eventCallbackData); + } + }, + err => { + throw err; + }, + ); + } else if (callbackResult) { + response.destroy(); + resolve(eventCallbackData); + } + eventContents = ''; + } else { + eventContents = eventContents.concat(char); + } + }); + }); + }); + + request.end(); + }); +} + +export function waitForEnvelopeItem( + proxyServerName: string, + callback: (envelopeItem: EnvelopeItem) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForRequest(proxyServerName, async eventData => { + const envelopeItems = eventData.envelope[1]; + for (const envelopeItem of envelopeItems) { + if (await callback(envelopeItem)) { + resolve(envelopeItem); + return true; + } + } + return false; + }).catch(reject); + }); +} + +export function waitForError( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +export function waitForTransaction( + proxyServerName: string, + callback: (transactionEvent: Event) => Promise | boolean, +): Promise { + return new Promise((resolve, reject) => { + waitForEnvelopeItem(proxyServerName, async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) { + resolve(envelopeItemBody as Event); + return true; + } + return false; + }).catch(reject); + }); +} + +const TEMP_FILE_PREFIX = 'event-proxy-server-'; + +async function registerCallbackServerPort(serverName: string, port: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + await writeFile(tmpFilePath, port, { encoding: 'utf8' }); +} + +function retrieveCallbackServerPort(serverName: string): Promise { + const tmpFilePath = path.join(os.tmpdir(), `${TEMP_FILE_PREFIX}${serverName}`); + return readFile(tmpFilePath, 'utf8'); +} diff --git a/packages/e2e-tests/test-applications/nextjs-14/globals.d.ts b/packages/e2e-tests/test-applications/nextjs-14/globals.d.ts new file mode 100644 index 000000000000..109dbcd55648 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/globals.d.ts @@ -0,0 +1,4 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; +} diff --git a/packages/e2e-tests/test-applications/nextjs-14/next-env.d.ts b/packages/e2e-tests/test-applications/nextjs-14/next-env.d.ts new file mode 100644 index 000000000000..4f11a03dc6cc --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/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/packages/e2e-tests/test-applications/nextjs-14/next.config.js b/packages/e2e-tests/test-applications/nextjs-14/next.config.js new file mode 100644 index 000000000000..4beb4fc356f4 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/next.config.js @@ -0,0 +1,30 @@ +// This file sets a custom webpack configuration to use your Next.js app +// with Sentry. +// https://nextjs.org/docs/api-reference/next.config.js/introduction +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +const { withSentryConfig } = require('@sentry/nextjs'); + +/** @type {import('next').NextConfig} */ +const moduleExports = {}; + +const sentryWebpackPluginOptions = { + // Additional config options for the Sentry Webpack plugin. Keep in mind that + // the following options are set automatically, and overriding them is not + // recommended: + // release, url, org, project, authToken, configFile, stripPrefix, + // urlPrefix, include, ignore + + silent: true, // Suppresses all logs + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options. + + // We're not testing source map uploads at the moment. + dryRun: true, +}; + +// Make sure adding Sentry options is the last code to run before exporting, to +// ensure that your source maps include changes from all other Webpack plugins +module.exports = withSentryConfig(moduleExports, sentryWebpackPluginOptions, { + hideSourceMaps: true, +}); diff --git a/packages/e2e-tests/test-applications/nextjs-14/package.json b/packages/e2e-tests/test-applications/nextjs-14/package.json new file mode 100644 index 000000000000..b822f4316566 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/package.json @@ -0,0 +1,35 @@ +{ + "name": "create-next-app", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr", + "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": { + "@sentry/nextjs": "latest || *", + "@types/node": "18.11.17", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.9", + "next": "14.0.4", + "react": "18.2.0", + "react-dom": "18.2.0", + "typescript": "4.9.5", + "wait-port": "1.0.4", + "ts-node": "10.9.1", + "@playwright/test": "^1.27.1" + }, + "devDependencies": { + "@sentry/types": "latest || *", + "@sentry/utils": "latest || *" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts b/packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts new file mode 100644 index 000000000000..ab3c40a21471 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts @@ -0,0 +1,77 @@ +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: 150_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, + /* 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}`, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'pnpm ts-node-script start-event-proxy.ts', + 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, + }, + ], +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/nextjs-14/sentry.client.config.ts b/packages/e2e-tests/test-applications/nextjs-14/sentry.client.config.ts new file mode 100644 index 000000000000..85bd765c9c44 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/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/packages/e2e-tests/test-applications/nextjs-14/sentry.edge.config.ts b/packages/e2e-tests/test-applications/nextjs-14/sentry.edge.config.ts new file mode 100644 index 000000000000..85bd765c9c44 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/sentry.edge.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/packages/e2e-tests/test-applications/nextjs-14/sentry.server.config.ts b/packages/e2e-tests/test-applications/nextjs-14/sentry.server.config.ts new file mode 100644 index 000000000000..85bd765c9c44 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/sentry.server.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/packages/e2e-tests/test-applications/nextjs-14/start-event-proxy.ts b/packages/e2e-tests/test-applications/nextjs-14/start-event-proxy.ts new file mode 100644 index 000000000000..eb83fd6fb82d --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/start-event-proxy.ts @@ -0,0 +1,6 @@ +import { startEventProxyServer } from './event-proxy-server'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-14', +}); diff --git a/packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts b/packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts new file mode 100644 index 000000000000..3828312607ea --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '../event-proxy-server'; + +test('Should send a transaction event for a generateMetadata() function invokation', async ({ page }) => { + const testTitle = 'foobarasdf'; + + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page.generateMetadata (/generation-functions)' && + transactionEvent.contexts?.trace?.data?.['searchParams']?.['metadataTitle'] === testTitle + ); + }); + + await page.goto(`/generation-functions?metadataTitle=${testTitle}`); + + expect(await transactionPromise).toBeDefined(); + + const pageTitle = await page.title(); + expect(pageTitle).toBe(testTitle); +}); + +test('Should send a transaction and an error event for a faulty generateMetadata() function invokation', async ({ + page, +}) => { + const testTitle = 'foobarbaz'; + + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page.generateMetadata (/generation-functions)' && + transactionEvent.contexts?.trace?.data?.['searchParams']?.['metadataTitle'] === testTitle + ); + }); + + const errorEventPromise = waitForError('nextjs-14', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'generateMetadata Error'; + }); + + await page.goto(`/generation-functions?metadataTitle=${testTitle}&shouldThrowInGenerateMetadata=1`); + + expect(await transactionPromise).toBeDefined(); + expect(await errorEventPromise).toBeDefined(); +}); + +test('Should send a transaction event for a generateViewport() function invokation', async ({ page }) => { + const testTitle = 'floob'; + + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page.generateViewport (/generation-functions)' && + transactionEvent.contexts?.trace?.data?.['searchParams']?.['viewportThemeColor'] === testTitle + ); + }); + + await page.goto(`/generation-functions?viewportThemeColor=${testTitle}`); + + expect(await transactionPromise).toBeDefined(); +}); + +test('Should send a transaction and an error event for a faulty generateViewport() function invokation', async ({ + page, +}) => { + const testTitle = 'blargh'; + + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page.generateViewport (/generation-functions)' && + transactionEvent.contexts?.trace?.data?.['searchParams']?.['viewportThemeColor'] === testTitle + ); + }); + + const errorEventPromise = waitForError('nextjs-14', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'generateViewport Error'; + }); + + await page.goto(`/generation-functions?viewportThemeColor=${testTitle}&shouldThrowInGenerateViewport=1`); + + expect(await transactionPromise).toBeDefined(); + expect(await errorEventPromise).toBeDefined(); +}); diff --git a/packages/e2e-tests/test-applications/nextjs-14/tsconfig.json b/packages/e2e-tests/test-applications/nextjs-14/tsconfig.json new file mode 100644 index 000000000000..60825545944d --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-14/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es5", + "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"], + "ts-node": { + "compilerOptions": { + "module": "CommonJS" + } + } +} diff --git a/packages/nextjs/src/common/index.ts b/packages/nextjs/src/common/index.ts index 063c11bff62a..3b0ce67fb16c 100644 --- a/packages/nextjs/src/common/index.ts +++ b/packages/nextjs/src/common/index.ts @@ -44,4 +44,6 @@ export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry'; export { wrapPageComponentWithSentry } from './wrapPageComponentWithSentry'; +export { wrapGenerationFunctionWithSentry } from './wrapGenerationFunctionWithSentry'; + export { withServerActionInstrumentation } from './withServerActionInstrumentation'; diff --git a/packages/nextjs/src/common/types.ts b/packages/nextjs/src/common/types.ts index ffca3dc8ff61..cf7d881e9ea0 100644 --- a/packages/nextjs/src/common/types.ts +++ b/packages/nextjs/src/common/types.ts @@ -1,5 +1,6 @@ import type { Transaction, WebFetchHeaders, WrappedFunction } from '@sentry/types'; import type { NextApiRequest, NextApiResponse } from 'next'; +import type { RequestAsyncStorage } from '../config/templates/requestAsyncStorageShim'; export type ServerComponentContext = { componentRoute: string; @@ -17,6 +18,13 @@ export type ServerComponentContext = { headers?: WebFetchHeaders; }; +export type GenerationFunctionContext = { + requestAsyncStorage?: RequestAsyncStorage; + componentRoute: string; + componentType: string; + generationFunctionIdentifier: string; +}; + export interface RouteHandlerContext { method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'; parameterizedRoute: string; diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts new file mode 100644 index 000000000000..5aa9c436beef --- /dev/null +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -0,0 +1,80 @@ +import { + addTracingExtensions, + captureException, + continueTrace, + getCurrentHub, + runWithAsyncContext, + trace, +} from '@sentry/core'; +import type { WebFetchHeaders } from '@sentry/types'; +import { winterCGHeadersToDict } from '@sentry/utils'; + +import type { GenerationFunctionContext } from '../common/types'; + +/** + * Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function wrapGenerationFunctionWithSentry any>( + generationFunction: F, + context: GenerationFunctionContext, +): F { + addTracingExtensions(); + const { requestAsyncStorage, componentRoute, componentType, generationFunctionIdentifier } = context; + return new Proxy(generationFunction, { + apply: (originalFunction, thisArg, args) => { + 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 (getCurrentHub().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 }; + } + + return runWithAsyncContext(() => { + const transactionContext = continueTrace({ + baggage: headers?.get('baggage'), + sentryTrace: headers?.get('sentry-trace') ?? undefined, + }); + return trace( + { + op: 'function.nextjs', + name: `${componentType}.${generationFunctionIdentifier} (${componentRoute})`, + origin: 'auto.function.nextjs', + ...transactionContext, + data, + metadata: { + ...transactionContext.metadata, + source: 'url', + request: { + headers: headers ? winterCGHeadersToDict(headers) : undefined, + }, + }, + }, + () => { + return originalFunction.apply(thisArg, args); + }, + err => { + captureException(err, { + mechanism: { + handled: false, + data: { + function: 'wrapGenerationFunctionWithSentry', + }, + }, + }); + }, + ); + }); + }, + }); +} diff --git a/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts b/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts index d0cc4adc4466..56b9853fa1af 100644 --- a/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts +++ b/packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts @@ -12,6 +12,9 @@ declare const requestAsyncStorage: RequestAsyncStorage; declare const serverComponentModule: { default: unknown; + generateMetadata?: () => unknown; + generateImageMetadata?: () => unknown; + generateViewport?: () => unknown; }; const serverComponent = serverComponentModule.default; @@ -30,14 +33,15 @@ if (typeof serverComponent === 'function') { // We try-catch here just in `requestAsyncStorage` is undefined since it may not be defined try { const requestAsyncStore = requestAsyncStorage.getStore(); - sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace'); - baggageHeader = requestAsyncStore?.headers.get('baggage'); + sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace') ?? undefined; + baggageHeader = requestAsyncStore?.headers.get('baggage') ?? undefined; headers = requestAsyncStore?.headers; } catch (e) { /** empty */ } - return Sentry.wrapServerComponentWithSentry(originalFunction, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + return Sentry.wrapServerComponentWithSentry(originalFunction as any, { componentRoute: '__ROUTE__', componentType: '__COMPONENT_TYPE__', sentryTraceHeader, @@ -50,6 +54,33 @@ if (typeof serverComponent === 'function') { wrappedServerComponent = serverComponent; } +export const generateMetadata = serverComponentModule.generateMetadata + ? Sentry.wrapGenerationFunctionWithSentry(serverComponentModule.generateMetadata, { + componentRoute: '__ROUTE__', + componentType: '__COMPONENT_TYPE__', + generationFunctionIdentifier: 'generateMetadata', + requestAsyncStorage, + }) + : undefined; + +export const generateImageMetadata = serverComponentModule.generateImageMetadata + ? Sentry.wrapGenerationFunctionWithSentry(serverComponentModule.generateImageMetadata, { + componentRoute: '__ROUTE__', + componentType: '__COMPONENT_TYPE__', + generationFunctionIdentifier: 'generateImageMetadata', + requestAsyncStorage, + }) + : undefined; + +export const generateViewport = serverComponentModule.generateViewport + ? Sentry.wrapGenerationFunctionWithSentry(serverComponentModule.generateViewport, { + componentRoute: '__ROUTE__', + componentType: '__COMPONENT_TYPE__', + generationFunctionIdentifier: 'generateViewport', + requestAsyncStorage, + }) + : undefined; + // Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to // not include anything whose name matchs something we've explicitly exported above. // @ts-expect-error See above