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