Skip to content

Commit 0e09695

Browse files
author
Luca Forstner
authored
feat(nextjs): Connect traces for server components (#7320)
2 parents 5e4e719 + 231e311 commit 0e09695

File tree

13 files changed

+230
-45
lines changed

13 files changed

+230
-45
lines changed

packages/e2e-tests/run.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ type RecipeResult = {
174174
type Recipe = {
175175
testApplicationName: string;
176176
buildCommand?: string;
177+
buildAssertionCommand?: string;
177178
buildTimeoutSeconds?: number;
178179
tests: {
179180
testName: string;
@@ -212,9 +213,9 @@ const recipeResults: RecipeResult[] = recipePaths.map(recipePath => {
212213
console.log(buildCommandProcess.stdout.replace(/^/gm, '[BUILD OUTPUT] '));
213214
console.log(buildCommandProcess.stderr.replace(/^/gm, '[BUILD OUTPUT] '));
214215

215-
const error: undefined | (Error & { code?: string }) = buildCommandProcess.error;
216+
const buildCommandProcessError: undefined | (Error & { code?: string }) = buildCommandProcess.error;
216217

217-
if (error?.code === 'ETIMEDOUT') {
218+
if (buildCommandProcessError?.code === 'ETIMEDOUT') {
218219
processShouldExitWithError = true;
219220

220221
printCIErrorMessage(
@@ -239,6 +240,58 @@ const recipeResults: RecipeResult[] = recipePaths.map(recipePath => {
239240
testResults: [],
240241
};
241242
}
243+
244+
if (recipe.buildAssertionCommand) {
245+
console.log(
246+
`Running E2E test build assertion for test application "${recipe.testApplicationName}"${dependencyOverridesInformationString}`,
247+
);
248+
const buildAssertionCommandProcess = childProcess.spawnSync(recipe.buildAssertionCommand, {
249+
cwd: path.dirname(recipePath),
250+
input: buildCommandProcess.stdout,
251+
encoding: 'utf8',
252+
shell: true, // needed so we can pass the build command in as whole without splitting it up into args
253+
timeout: (recipe.buildTimeoutSeconds ?? DEFAULT_BUILD_TIMEOUT_SECONDS) * 1000,
254+
env: {
255+
...process.env,
256+
...envVarsToInject,
257+
},
258+
});
259+
260+
// Prepends some text to the output build command's output so we can distinguish it from logging in this script
261+
console.log(buildAssertionCommandProcess.stdout.replace(/^/gm, '[BUILD ASSERTION OUTPUT] '));
262+
console.log(buildAssertionCommandProcess.stderr.replace(/^/gm, '[BUILD ASSERTION OUTPUT] '));
263+
264+
const buildAssertionCommandProcessError: undefined | (Error & { code?: string }) =
265+
buildAssertionCommandProcess.error;
266+
267+
if (buildAssertionCommandProcessError?.code === 'ETIMEDOUT') {
268+
processShouldExitWithError = true;
269+
270+
printCIErrorMessage(
271+
`Build assertion in test application "${recipe.testApplicationName}" (${path.dirname(
272+
recipePath,
273+
)}) timed out!`,
274+
);
275+
276+
return {
277+
dependencyOverrides,
278+
buildFailed: true,
279+
testResults: [],
280+
};
281+
} else if (buildAssertionCommandProcess.status !== 0) {
282+
processShouldExitWithError = true;
283+
284+
printCIErrorMessage(
285+
`Build assertion in test application "${recipe.testApplicationName}" (${path.dirname(recipePath)}) failed!`,
286+
);
287+
288+
return {
289+
dependencyOverrides,
290+
buildFailed: true,
291+
testResults: [],
292+
};
293+
}
294+
}
242295
}
243296

244297
const testResults: TestResult[] = recipe.tests.map(test => {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as fs from 'fs';
2+
import * as assert from 'assert/strict';
3+
4+
const stdin = fs.readFileSync(0).toString();
5+
6+
// Assert that all static components stay static and ally dynamic components stay dynamic
7+
8+
assert.match(stdin, / \/client-component/);
9+
assert.match(stdin, / \/client-component\/parameter\/\[\.\.\.parameters\]/);
10+
assert.match(stdin, / \/client-component\/parameter\/\[parameter\]/);
11+
12+
assert.match(stdin, /λ \/server-component/);
13+
assert.match(stdin, /λ \/server-component\/parameter\/\[\.\.\.parameters\]/);
14+
assert.match(stdin, /λ \/server-component\/parameter\/\[parameter\]/);
15+
16+
export {};

packages/e2e-tests/test-applications/nextjs-app-dir/components/client-error-debug-tools.tsx

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { TransactionContext } from './transaction-context';
55
import { captureException } from '@sentry/nextjs';
66

77
export function ClientErrorDebugTools() {
8-
const { transactionActive, toggle } = useContext(TransactionContext);
8+
const transactionContextValue = useContext(TransactionContext);
9+
const [transactionName, setTransactionName] = useState<string>('');
910

1011
const [isFetchingAPIRoute, setIsFetchingAPIRoute] = useState<boolean>();
1112
const [isFetchingEdgeAPIRoute, setIsFetchingEdgeAPIRoute] = useState<boolean>();
@@ -18,13 +19,34 @@ export function ClientErrorDebugTools() {
1819

1920
return (
2021
<div>
21-
<button
22-
onClick={() => {
23-
toggle();
24-
}}
25-
>
26-
{transactionActive ? 'Stop Transaction' : 'Start Transaction'}
27-
</button>
22+
{transactionContextValue.transactionActive ? (
23+
<button
24+
onClick={() => {
25+
transactionContextValue.stop();
26+
setTransactionName('');
27+
}}
28+
>
29+
Stop transaction
30+
</button>
31+
) : (
32+
<>
33+
<input
34+
type="text"
35+
placeholder="Transaction name"
36+
value={transactionName}
37+
onChange={e => {
38+
setTransactionName(e.target.value);
39+
}}
40+
/>
41+
<button
42+
onClick={() => {
43+
transactionContextValue.start(transactionName);
44+
}}
45+
>
46+
Start transaction
47+
</button>
48+
</>
49+
)}
2850
<br />
2951
<br />
3052
<button

packages/e2e-tests/test-applications/nextjs-app-dir/components/transaction-context.tsx

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,36 @@ import { createContext, PropsWithChildren, useState } from 'react';
44
import { Transaction } from '@sentry/types';
55
import { startTransaction, getCurrentHub } from '@sentry/nextjs';
66

7-
export const TransactionContext = createContext<{ transactionActive: boolean; toggle: () => void }>({
7+
export const TransactionContext = createContext<
8+
{ transactionActive: false; start: (transactionName: string) => void } | { transactionActive: true; stop: () => void }
9+
>({
810
transactionActive: false,
9-
toggle: () => undefined,
11+
start: () => undefined,
1012
});
1113

1214
export function TransactionContextProvider({ children }: PropsWithChildren) {
1315
const [transaction, setTransaction] = useState<Transaction | undefined>(undefined);
1416

1517
return (
1618
<TransactionContext.Provider
17-
value={{
18-
transactionActive: !!transaction,
19-
toggle: () => {
20-
if (transaction) {
21-
transaction.finish();
22-
setTransaction(undefined);
23-
} else {
24-
const t = startTransaction({ name: 'Manual Transaction' });
25-
getCurrentHub().getScope()?.setSpan(t);
26-
setTransaction(t);
27-
}
28-
},
29-
}}
19+
value={
20+
transaction
21+
? {
22+
transactionActive: true,
23+
stop: () => {
24+
transaction.finish();
25+
setTransaction(undefined);
26+
},
27+
}
28+
: {
29+
transactionActive: false,
30+
start: (transactionName: string) => {
31+
const t = startTransaction({ name: transactionName });
32+
getCurrentHub().getScope()?.setSpan(t);
33+
setTransaction(t);
34+
},
35+
}
36+
}
3037
>
3138
{children}
3239
</TransactionContext.Provider>

packages/e2e-tests/test-applications/nextjs-app-dir/test-recipe.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"$schema": "../../test-recipe-schema.json",
33
"testApplicationName": "nextjs-13-app-dir",
44
"buildCommand": "yarn install --pure-lockfile && npx playwright install && yarn build",
5+
"buildAssertionCommand": "yarn ts-node --script-mode assert-build.ts",
56
"tests": [
67
{
78
"testName": "Prod Mode",

packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ test.describe('dev mode error symbolification', () => {
2121

2222
expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual(
2323
expect.objectContaining({
24+
function: 'onClick',
2425
filename: 'components/client-error-debug-tools.tsx',
2526
abs_path: 'webpack-internal:///(app-client)/./components/client-error-debug-tools.tsx',
26-
function: 'onClick',
27-
in_app: true,
28-
lineno: 32,
27+
lineno: 54,
2928
colno: 16,
30-
post_context: [' }}', ' >', ' Throw error'],
31-
context_line: " throw new Error('Click Error');",
29+
in_app: true,
3230
pre_context: [' <button', ' onClick={() => {'],
31+
context_line: " throw new Error('Click Error');",
32+
post_context: [' }}', ' >', ' Throw error'],
3333
}),
3434
);
3535
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { test } from '@playwright/test';
2+
import { waitForTransaction } from '../../../test-utils/event-proxy-server';
3+
4+
if (process.env.TEST_ENV === 'production') {
5+
// TODO: Fix that this is flakey on dev server - might be an SDK bug
6+
test('Sends connected traces for server components', async ({ page }, testInfo) => {
7+
await page.goto('/client-component');
8+
9+
const clientTransactionName = `e2e-next-js-app-dir: ${testInfo.title}`;
10+
11+
const serverComponentTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
12+
return (
13+
transactionEvent?.transaction === 'Page Server Component (/server-component)' &&
14+
(await clientTransactionPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id
15+
);
16+
});
17+
18+
const clientTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => {
19+
return transactionEvent?.transaction === clientTransactionName;
20+
});
21+
22+
await page.getByPlaceholder('Transaction name').fill(clientTransactionName);
23+
await page.getByText('Start transaction').click();
24+
await page.getByRole('link', { name: /^\/server-component$/ }).click();
25+
await page.getByText('Page (/server-component)').isVisible();
26+
await page.getByText('Stop transaction').click();
27+
28+
await serverComponentTransaction;
29+
});
30+
}

packages/e2e-tests/test-recipe-schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
"type": "string",
1212
"description": "Command that is run to install dependencies and build the test application. This command is only run once before all tests. Working directory of the command is the root of the test application."
1313
},
14+
"buildAssertionCommand": {
15+
"type": "string",
16+
"description": "Command to verify build output. This command will be run after the build is complete. The command will receive the STDOUT of the `buildCommand` as STDIN."
17+
},
1418
"buildTimeoutSeconds": {
1519
"type": "number",
1620
"description": "Timeout for the build command in seconds. Default: 60"

packages/e2e-tests/test-utils/event-proxy-server.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export async function startEventProxyServer(options: EventProxyServerOptions): P
138138

139139
export async function waitForRequest(
140140
proxyServerName: string,
141-
callback: (eventData: SentryRequestCallbackData) => boolean,
141+
callback: (eventData: SentryRequestCallbackData) => Promise<boolean> | boolean,
142142
): Promise<SentryRequestCallbackData> {
143143
const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName);
144144

@@ -157,7 +157,20 @@ export async function waitForRequest(
157157
const eventCallbackData: SentryRequestCallbackData = JSON.parse(
158158
Buffer.from(eventContents, 'base64').toString('utf8'),
159159
);
160-
if (callback(eventCallbackData)) {
160+
const callbackResult = callback(eventCallbackData);
161+
if (typeof callbackResult !== 'boolean') {
162+
callbackResult.then(
163+
match => {
164+
if (match) {
165+
response.destroy();
166+
resolve(eventCallbackData);
167+
}
168+
},
169+
err => {
170+
throw err;
171+
},
172+
);
173+
} else if (callbackResult) {
161174
response.destroy();
162175
resolve(eventCallbackData);
163176
}
@@ -175,13 +188,13 @@ export async function waitForRequest(
175188

176189
export function waitForEnvelopeItem(
177190
proxyServerName: string,
178-
callback: (envelopeItem: EnvelopeItem) => boolean,
191+
callback: (envelopeItem: EnvelopeItem) => Promise<boolean> | boolean,
179192
): Promise<EnvelopeItem> {
180193
return new Promise((resolve, reject) => {
181-
waitForRequest(proxyServerName, eventData => {
194+
waitForRequest(proxyServerName, async eventData => {
182195
const envelopeItems = eventData.envelope[1];
183196
for (const envelopeItem of envelopeItems) {
184-
if (callback(envelopeItem)) {
197+
if (await callback(envelopeItem)) {
185198
resolve(envelopeItem);
186199
return true;
187200
}
@@ -191,11 +204,14 @@ export function waitForEnvelopeItem(
191204
});
192205
}
193206

194-
export function waitForError(proxyServerName: string, callback: (transactionEvent: Event) => boolean): Promise<Event> {
207+
export function waitForError(
208+
proxyServerName: string,
209+
callback: (transactionEvent: Event) => Promise<boolean> | boolean,
210+
): Promise<Event> {
195211
return new Promise((resolve, reject) => {
196-
waitForEnvelopeItem(proxyServerName, envelopeItem => {
212+
waitForEnvelopeItem(proxyServerName, async envelopeItem => {
197213
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;
198-
if (envelopeItemHeader.type === 'event' && callback(envelopeItemBody as Event)) {
214+
if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) {
199215
resolve(envelopeItemBody as Event);
200216
return true;
201217
}
@@ -206,12 +222,12 @@ export function waitForError(proxyServerName: string, callback: (transactionEven
206222

207223
export function waitForTransaction(
208224
proxyServerName: string,
209-
callback: (transactionEvent: Event) => boolean,
225+
callback: (transactionEvent: Event) => Promise<boolean> | boolean,
210226
): Promise<Event> {
211227
return new Promise((resolve, reject) => {
212-
waitForEnvelopeItem(proxyServerName, envelopeItem => {
228+
waitForEnvelopeItem(proxyServerName, async envelopeItem => {
213229
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;
214-
if (envelopeItemHeader.type === 'transaction' && callback(envelopeItemBody as Event)) {
230+
if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) {
215231
resolve(envelopeItemBody as Event);
216232
return true;
217233
}

packages/nextjs/src/common/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export type ServerComponentContext = {
22
componentRoute: string;
33
componentType: string;
4+
sentryTraceHeader?: string;
5+
baggageHeader?: string;
46
};

0 commit comments

Comments
 (0)