Skip to content

Commit f121585

Browse files
authored
feat(nextjs): Connect server component transactions if there is no incoming trace (#9845)
1 parent b27c236 commit f121585

File tree

8 files changed

+144
-3
lines changed

8 files changed

+144
-3
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { PropsWithChildren } from 'react';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
export default function Layout({ children }: PropsWithChildren<{}>) {
6+
return (
7+
<div>
8+
<p>Layout</p>
9+
{children}
10+
</div>
11+
);
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { PropsWithChildren } from 'react';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
export default function Layout({ children }: PropsWithChildren<{}>) {
6+
return (
7+
<div>
8+
<p>Layout</p>
9+
{children}
10+
</div>
11+
);
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const dynamic = 'force-dynamic';
2+
3+
export default function Page() {
4+
return <p>Hello World!</p>;
5+
}
6+
7+
export async function generateMetadata() {
8+
return {
9+
title: 'I am generated metadata',
10+
};
11+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '../event-proxy-server';
3+
4+
test('Will capture a connected trace for all server components and generation functions when visiting a page', async ({
5+
page,
6+
}) => {
7+
const someConnectedEvent = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
8+
return (
9+
transactionEvent?.transaction === 'Layout Server Component (/(nested-layout)/nested-layout)' ||
10+
transactionEvent?.transaction === 'Layout Server Component (/(nested-layout))' ||
11+
transactionEvent?.transaction === 'Page Server Component (/(nested-layout)/nested-layout)' ||
12+
transactionEvent?.transaction === 'Page.generateMetadata (/(nested-layout)/nested-layout)'
13+
);
14+
});
15+
16+
const layout1Transaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
17+
return (
18+
transactionEvent?.transaction === 'Layout Server Component (/(nested-layout)/nested-layout)' &&
19+
(await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id
20+
);
21+
});
22+
23+
const layout2Transaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
24+
return (
25+
transactionEvent?.transaction === 'Layout Server Component (/(nested-layout))' &&
26+
(await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id
27+
);
28+
});
29+
30+
const pageTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
31+
return (
32+
transactionEvent?.transaction === 'Page Server Component (/(nested-layout)/nested-layout)' &&
33+
(await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id
34+
);
35+
});
36+
37+
const generateMetadataTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
38+
return (
39+
transactionEvent?.transaction === 'Page.generateMetadata (/(nested-layout)/nested-layout)' &&
40+
(await someConnectedEvent).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id
41+
);
42+
});
43+
44+
await page.goto('/nested-layout');
45+
46+
expect(await layout1Transaction).toBeDefined();
47+
expect(await layout2Transaction).toBeDefined();
48+
expect(await pageTransaction).toBeDefined();
49+
expect(await generateMetadataTransaction).toBeDefined();
50+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { PropagationContext } from '@sentry/types';
2+
3+
const commonMap = new WeakMap<object, PropagationContext>();
4+
5+
/**
6+
* 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.
7+
*/
8+
export function commonObjectToPropagationContext(
9+
commonObject: unknown,
10+
propagationContext: PropagationContext,
11+
): PropagationContext {
12+
if (typeof commonObject === 'object' && commonObject) {
13+
const memoPropagationContext = commonMap.get(commonObject);
14+
if (memoPropagationContext) {
15+
return memoPropagationContext;
16+
} else {
17+
commonMap.set(commonObject, propagationContext);
18+
return propagationContext;
19+
}
20+
} else {
21+
return propagationContext;
22+
}
23+
}

packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import {
33
captureException,
44
continueTrace,
55
getCurrentHub,
6+
getCurrentScope,
67
runWithAsyncContext,
78
trace,
89
} from '@sentry/core';
910
import type { WebFetchHeaders } from '@sentry/types';
1011
import { winterCGHeadersToDict } from '@sentry/utils';
1112

1213
import type { GenerationFunctionContext } from '../common/types';
14+
import { commonObjectToPropagationContext } from './utils/commonObjectTracing';
1315

1416
/**
1517
* Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation.
@@ -45,6 +47,19 @@ export function wrapGenerationFunctionWithSentry<F extends (...args: any[]) => a
4547
baggage: headers?.get('baggage'),
4648
sentryTrace: headers?.get('sentry-trace') ?? undefined,
4749
});
50+
51+
// If there is no incoming trace, we are setting the transaction context to one that is shared between all other
52+
// transactions for this request. We do this based on the `headers` object, which is the same for all components.
53+
const propagationContext = getCurrentScope().getPropagationContext();
54+
if (!transactionContext.traceId && !transactionContext.parentSpanId) {
55+
const { traceId: commonTraceId, spanId: commonSpanId } = commonObjectToPropagationContext(
56+
headers,
57+
propagationContext,
58+
);
59+
transactionContext.traceId = commonTraceId;
60+
transactionContext.parentSpanId = commonSpanId;
61+
}
62+
4863
return trace(
4964
{
5065
op: 'function.nextjs',

packages/nextjs/src/common/wrapServerComponentWithSentry.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
import { addTracingExtensions, captureException, continueTrace, runWithAsyncContext, trace } from '@sentry/core';
1+
import {
2+
addTracingExtensions,
3+
captureException,
4+
continueTrace,
5+
getCurrentScope,
6+
runWithAsyncContext,
7+
trace,
8+
} from '@sentry/core';
29
import { winterCGHeadersToDict } from '@sentry/utils';
310

411
import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils';
512
import type { ServerComponentContext } from '../common/types';
13+
import { commonObjectToPropagationContext } from './utils/commonObjectTracing';
614
import { flushQueue } from './utils/responseEnd';
715

816
/**
@@ -33,6 +41,18 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => any>
3341
baggage: context.baggageHeader ?? completeHeadersDict['baggage'],
3442
});
3543

44+
// If there is no incoming trace, we are setting the transaction context to one that is shared between all other
45+
// transactions for this request. We do this based on the `headers` object, which is the same for all components.
46+
const propagationContext = getCurrentScope().getPropagationContext();
47+
if (!transactionContext.traceId && !transactionContext.parentSpanId) {
48+
const { traceId: commonTraceId, spanId: commonSpanId } = commonObjectToPropagationContext(
49+
context.headers,
50+
propagationContext,
51+
);
52+
transactionContext.traceId = commonTraceId;
53+
transactionContext.parentSpanId = commonSpanId;
54+
}
55+
3656
const res = trace(
3757
{
3858
...transactionContext,

packages/nextjs/src/config/loaders/wrappingLoader.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,6 @@ export default function wrappingLoader(
157157
.replace(/(.*)/, '/$1')
158158
// Pull off the file name
159159
.replace(/\/[^/]+\.(js|ts|jsx|tsx)$/, '')
160-
// Remove routing groups: https://beta.nextjs.org/docs/routing/defining-routes#example-creating-multiple-root-layouts
161-
.replace(/\/(\(.*?\)\/)+/g, '/')
162160
// In case all of the above have left us with an empty string (which will happen if we're dealing with the
163161
// homepage), sub back in the root route
164162
.replace(/^$/, '/');

0 commit comments

Comments
 (0)