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/package.json b/packages/nextjs/package.json
index fc4febf9b75a..1bdd43e5c575 100644
--- a/packages/nextjs/package.json
+++ b/packages/nextjs/package.json
@@ -66,6 +66,7 @@
},
"dependencies": {
"@opentelemetry/instrumentation-http": "0.51.1",
+ "@opentelemetry/api": "^1.8.0",
"@rollup/plugin-commonjs": "24.0.0",
"@sentry/core": "8.2.1",
"@sentry/node": "8.2.1",
diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
index 25c1496d25b4..1a7855a0ff78 100644
--- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
+++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
@@ -5,10 +5,39 @@ import {
} from '@sentry/core';
import { WINDOW, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan } from '@sentry/react';
import type { Client } from '@sentry/types';
-import { addFetchInstrumentationHandler, browserPerformanceTimeOrigin } from '@sentry/utils';
+import { addFetchInstrumentationHandler, browserPerformanceTimeOrigin, parseBaggageHeader } from '@sentry/utils';
/** Instruments the Next.js app router for pageloads. */
export function appRouterInstrumentPageLoad(client: Client): void {
+ // We use an event processor to override the automatically collected Request Browser metric span ID with the span ID
+ // hint from the server so that the SSR spans are properly attached to the request span.
+ client.addEventProcessor(event => {
+ if (event.type !== 'transaction' || event.contexts?.trace?.op !== 'pageload') {
+ return event;
+ }
+
+ const baggage = WINDOW.document.querySelector('meta[name=baggage]')?.getAttribute('content');
+ if (baggage) {
+ const parsedBaggage = parseBaggageHeader(baggage);
+ if (parsedBaggage && parsedBaggage['sentry-request-span-id-suggestion']) {
+ const spanIdSuggestion = parsedBaggage['sentry-request-span-id-suggestion'];
+ event.spans?.forEach(span => {
+ if (span.description === 'request' && span.op === 'browser') {
+ // Replace request span ID
+ span.span_id = spanIdSuggestion;
+
+ if (event.contexts?.trace) {
+ // Unset the parent span of the pageload transaction - it is now the root of the trace
+ event.contexts.trace.parent_span_id = undefined;
+ }
+ }
+ });
+ }
+ }
+
+ return event;
+ });
+
startBrowserTracingPageLoadSpan(client, {
name: WINDOW.location.pathname,
// pageload should always start at timeOrigin (and needs to be in s, not ms)
diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts
index f3998b693b38..fdfea1f5f402 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,33 @@ 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),
+ parentSpanId: otelContext
+ .active()
+ .getValue(EXPERIMENTAL_SENTRY_REQUEST_SPAN_ID_SUGGESTION_CONTEXT_KEY) as string | undefined,
+ },
+ );
+
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..cd8fee278a6e 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,35 @@ 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),
+ parentSpanId: otelContext
+ .active()
+ .getValue(EXPERIMENTAL_SENTRY_REQUEST_SPAN_ID_SUGGESTION_CONTEXT_KEY) as string | undefined,
+ },
+ );
+
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;
+ }
+}
diff --git a/packages/nextjs/src/server/httpIntegration.ts b/packages/nextjs/src/server/httpIntegration.ts
index 4fdc615deb92..fe0be2d916d3 100644
--- a/packages/nextjs/src/server/httpIntegration.ts
+++ b/packages/nextjs/src/server/httpIntegration.ts
@@ -1,6 +1,9 @@
+import { context } from '@opentelemetry/api';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { httpIntegration as originalHttpIntegration } from '@sentry/node';
+import { EXPERIMENTAL_SENTRY_REQUEST_SPAN_ID_SUGGESTION_CONTEXT_KEY } from '@sentry/opentelemetry';
import type { IntegrationFn } from '@sentry/types';
+import { uuid4 } from '@sentry/utils';
/**
* Next.js handles incoming requests itself,
@@ -16,7 +19,15 @@ class CustomNextjsHttpIntegration extends HttpInstrumentation {
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]);
+ const requestSpanIdSuggestion = uuid4().substring(16);
+ return context.with(
+ context
+ .active()
+ .setValue(EXPERIMENTAL_SENTRY_REQUEST_SPAN_ID_SUGGESTION_CONTEXT_KEY, requestSpanIdSuggestion),
+ () => {
+ return original.apply(this, [event, ...args]);
+ },
+ );
};
};
}
diff --git a/packages/nextjs/test/performance/appRouterInstrumentation.test.ts b/packages/nextjs/test/performance/appRouterInstrumentation.test.ts
index 16992a498f83..3f65fcd951e6 100644
--- a/packages/nextjs/test/performance/appRouterInstrumentation.test.ts
+++ b/packages/nextjs/test/performance/appRouterInstrumentation.test.ts
@@ -36,6 +36,7 @@ describe('appRouterInstrumentPageLoad', () => {
const emit = jest.fn();
const client = {
emit,
+ addEventProcessor: jest.fn(),
} as unknown as Client;
appRouterInstrumentPageLoad(client);
diff --git a/packages/opentelemetry/src/constants.ts b/packages/opentelemetry/src/constants.ts
index 0f056d09a4ea..785fdf154880 100644
--- a/packages/opentelemetry/src/constants.ts
+++ b/packages/opentelemetry/src/constants.ts
@@ -15,3 +15,7 @@ export const SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY = createContextKey('sentry_
export const SENTRY_FORK_SET_SCOPE_CONTEXT_KEY = createContextKey('sentry_fork_set_scope');
export const SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY = createContextKey('sentry_fork_set_isolation_scope');
+
+export const EXPERIMENTAL_SENTRY_REQUEST_SPAN_ID_SUGGESTION_CONTEXT_KEY = createContextKey(
+ 'sentry_request_span_id_suggestion',
+);
diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts
index 97ed2f2e4764..e95e34cd7caf 100644
--- a/packages/opentelemetry/src/index.ts
+++ b/packages/opentelemetry/src/index.ts
@@ -39,5 +39,7 @@ export { openTelemetrySetupCheck } from './utils/setupCheck';
export { addOpenTelemetryInstrumentation } from './instrumentation';
+export { EXPERIMENTAL_SENTRY_REQUEST_SPAN_ID_SUGGESTION_CONTEXT_KEY } from './constants';
+
// Legacy
export { getClient } from '@sentry/core';
diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts
index 45dcf811eaa8..880f45518db6 100644
--- a/packages/opentelemetry/src/propagator.ts
+++ b/packages/opentelemetry/src/propagator.ts
@@ -22,6 +22,7 @@ import {
} from '@sentry/utils';
import {
+ EXPERIMENTAL_SENTRY_REQUEST_SPAN_ID_SUGGESTION_CONTEXT_KEY,
SENTRY_BAGGAGE_HEADER,
SENTRY_TRACE_HEADER,
SENTRY_TRACE_STATE_DSC,
@@ -131,6 +132,15 @@ export class SentryPropagator extends W3CBaggagePropagator {
setter.set(carrier, SENTRY_TRACE_HEADER, generateSentryTraceHeader(traceId, spanId, sampled));
}
+ const requestSpanIdSuggestion = context.getValue(EXPERIMENTAL_SENTRY_REQUEST_SPAN_ID_SUGGESTION_CONTEXT_KEY) as
+ | string
+ | undefined;
+ if (requestSpanIdSuggestion) {
+ baggage = baggage.setEntry(`${SENTRY_BAGGAGE_KEY_PREFIX}request-span-id-suggestion`, {
+ value: requestSpanIdSuggestion,
+ });
+ }
+
super.inject(propagation.setBaggage(context, baggage), carrier, setter);
}