diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/layout.tsx b/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/layout.tsx new file mode 100644 index 000000000000..ace0c2f086b7 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
+

Layout

+ {children} +
+ ); +} diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/nested-layout/layout.tsx b/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/nested-layout/layout.tsx new file mode 100644 index 000000000000..ace0c2f086b7 --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/nested-layout/layout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default function Layout({ children }: PropsWithChildren<{}>) { + return ( +
+

Layout

+ {children} +
+ ); +} diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/nested-layout/page.tsx b/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/nested-layout/page.tsx new file mode 100644 index 000000000000..8077c14d23ca --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/app/(nested-layout)/nested-layout/page.tsx @@ -0,0 +1,11 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Hello World!

; +} + +export async function generateMetadata() { + return { + title: 'I am generated metadata', + }; +} diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts new file mode 100644 index 000000000000..4acc41814d3c --- /dev/null +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '../event-proxy-server'; + +test('Will capture a connected trace for all server components and generation functions when visiting a page', async ({ + page, +}) => { + const someConnectedEvent = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Layout Server Component (/(nested-layout)/nested-layout)' || + transactionEvent?.transaction === 'Layout Server Component (/(nested-layout))' || + transactionEvent?.transaction === 'Page Server Component (/(nested-layout)/nested-layout)' || + transactionEvent?.transaction === 'Page.generateMetadata (/(nested-layout)/nested-layout)' + ); + }); + + const layout1Transaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Layout Server Component (/(nested-layout)/nested-layout)' && + (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id + ); + }); + + const layout2Transaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Layout Server Component (/(nested-layout))' && + (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id + ); + }); + + const pageTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page Server Component (/(nested-layout)/nested-layout)' && + (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id + ); + }); + + const generateMetadataTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'Page.generateMetadata (/(nested-layout)/nested-layout)' && + (await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id + ); + }); + + await page.goto('/nested-layout'); + + expect(await layout1Transaction).toBeDefined(); + expect(await layout2Transaction).toBeDefined(); + expect(await pageTransaction).toBeDefined(); + expect(await generateMetadataTransaction).toBeDefined(); +}); diff --git a/packages/nextjs/src/common/utils/commonObjectTracing.ts b/packages/nextjs/src/common/utils/commonObjectTracing.ts new file mode 100644 index 000000000000..bb5cf130bab1 --- /dev/null +++ b/packages/nextjs/src/common/utils/commonObjectTracing.ts @@ -0,0 +1,23 @@ +import type { PropagationContext } from '@sentry/types'; + +const commonMap = new WeakMap(); + +/** + * Takes a shared (garbage collectable) object between resources, e.g. a headers object shared between Next.js server components and returns a common propagation context. + */ +export function commonObjectToPropagationContext( + commonObject: unknown, + propagationContext: PropagationContext, +): PropagationContext { + if (typeof commonObject === 'object' && commonObject) { + const memoPropagationContext = commonMap.get(commonObject); + if (memoPropagationContext) { + return memoPropagationContext; + } else { + commonMap.set(commonObject, propagationContext); + return propagationContext; + } + } else { + return propagationContext; + } +} diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 5aa9c436beef..80f7d62cc447 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -3,6 +3,7 @@ import { captureException, continueTrace, getCurrentHub, + getCurrentScope, runWithAsyncContext, trace, } from '@sentry/core'; @@ -10,6 +11,7 @@ import type { WebFetchHeaders } from '@sentry/types'; import { winterCGHeadersToDict } from '@sentry/utils'; import type { GenerationFunctionContext } from '../common/types'; +import { commonObjectToPropagationContext } from './utils/commonObjectTracing'; /** * Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation. @@ -45,6 +47,19 @@ export function wrapGenerationFunctionWithSentry a baggage: headers?.get('baggage'), sentryTrace: headers?.get('sentry-trace') ?? undefined, }); + + // If there is no incoming trace, we are setting the transaction context to one that is shared between all other + // transactions for this request. We do this based on the `headers` object, which is the same for all components. + const propagationContext = getCurrentScope().getPropagationContext(); + if (!transactionContext.traceId && !transactionContext.parentSpanId) { + const { traceId: commonTraceId, spanId: commonSpanId } = commonObjectToPropagationContext( + headers, + propagationContext, + ); + transactionContext.traceId = commonTraceId; + transactionContext.parentSpanId = commonSpanId; + } + return trace( { op: 'function.nextjs', diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index d7ff31f3afd9..8312121ae12c 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -1,8 +1,16 @@ -import { addTracingExtensions, captureException, continueTrace, runWithAsyncContext, trace } from '@sentry/core'; +import { + addTracingExtensions, + captureException, + continueTrace, + getCurrentScope, + runWithAsyncContext, + trace, +} from '@sentry/core'; import { winterCGHeadersToDict } from '@sentry/utils'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import type { ServerComponentContext } from '../common/types'; +import { commonObjectToPropagationContext } from './utils/commonObjectTracing'; import { flushQueue } from './utils/responseEnd'; /** @@ -33,6 +41,18 @@ export function wrapServerComponentWithSentry any> baggage: context.baggageHeader ?? completeHeadersDict['baggage'], }); + // If there is no incoming trace, we are setting the transaction context to one that is shared between all other + // transactions for this request. We do this based on the `headers` object, which is the same for all components. + const propagationContext = getCurrentScope().getPropagationContext(); + if (!transactionContext.traceId && !transactionContext.parentSpanId) { + const { traceId: commonTraceId, spanId: commonSpanId } = commonObjectToPropagationContext( + context.headers, + propagationContext, + ); + transactionContext.traceId = commonTraceId; + transactionContext.parentSpanId = commonSpanId; + } + const res = trace( { ...transactionContext, diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index a6b852af8b28..3d2fd7c80bb4 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -157,8 +157,6 @@ export default function wrappingLoader( .replace(/(.*)/, '/$1') // Pull off the file name .replace(/\/[^/]+\.(js|ts|jsx|tsx)$/, '') - // Remove routing groups: https://beta.nextjs.org/docs/routing/defining-routes#example-creating-multiple-root-layouts - .replace(/\/(\(.*?\)\/)+/g, '/') // In case all of the above have left us with an empty string (which will happen if we're dealing with the // homepage), sub back in the root route .replace(/^$/, '/');