Skip to content

feat(nextjs): Connect traces for server components #7320

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions packages/e2e-tests/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ type RecipeResult = {
type Recipe = {
testApplicationName: string;
buildCommand?: string;
buildAssertionCommand?: string;
buildTimeoutSeconds?: number;
tests: {
testName: string;
Expand Down Expand Up @@ -212,9 +213,9 @@ const recipeResults: RecipeResult[] = recipePaths.map(recipePath => {
console.log(buildCommandProcess.stdout.replace(/^/gm, '[BUILD OUTPUT] '));
console.log(buildCommandProcess.stderr.replace(/^/gm, '[BUILD OUTPUT] '));

const error: undefined | (Error & { code?: string }) = buildCommandProcess.error;
const buildCommandProcessError: undefined | (Error & { code?: string }) = buildCommandProcess.error;

if (error?.code === 'ETIMEDOUT') {
if (buildCommandProcessError?.code === 'ETIMEDOUT') {
processShouldExitWithError = true;

printCIErrorMessage(
Expand All @@ -239,6 +240,58 @@ const recipeResults: RecipeResult[] = recipePaths.map(recipePath => {
testResults: [],
};
}

if (recipe.buildAssertionCommand) {
console.log(
`Running E2E test build assertion for test application "${recipe.testApplicationName}"${dependencyOverridesInformationString}`,
);
const buildAssertionCommandProcess = childProcess.spawnSync(recipe.buildAssertionCommand, {
cwd: path.dirname(recipePath),
input: buildCommandProcess.stdout,
encoding: 'utf8',
shell: true, // needed so we can pass the build command in as whole without splitting it up into args
timeout: (recipe.buildTimeoutSeconds ?? DEFAULT_BUILD_TIMEOUT_SECONDS) * 1000,
env: {
...process.env,
...envVarsToInject,
},
});

// Prepends some text to the output build command's output so we can distinguish it from logging in this script
console.log(buildAssertionCommandProcess.stdout.replace(/^/gm, '[BUILD ASSERTION OUTPUT] '));
console.log(buildAssertionCommandProcess.stderr.replace(/^/gm, '[BUILD ASSERTION OUTPUT] '));

const buildAssertionCommandProcessError: undefined | (Error & { code?: string }) =
buildAssertionCommandProcess.error;

if (buildAssertionCommandProcessError?.code === 'ETIMEDOUT') {
processShouldExitWithError = true;

printCIErrorMessage(
`Build assertion in test application "${recipe.testApplicationName}" (${path.dirname(
recipePath,
)}) timed out!`,
);

return {
dependencyOverrides,
buildFailed: true,
testResults: [],
};
} else if (buildAssertionCommandProcess.status !== 0) {
processShouldExitWithError = true;

printCIErrorMessage(
`Build assertion in test application "${recipe.testApplicationName}" (${path.dirname(recipePath)}) failed!`,
);

return {
dependencyOverrides,
buildFailed: true,
testResults: [],
};
}
}
}

const testResults: TestResult[] = recipe.tests.map(test => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as fs from 'fs';
import * as assert from 'assert/strict';

const stdin = fs.readFileSync(0).toString();

// Assert that all static components stay static and ally dynamic components stay dynamic

assert.match(stdin, /○ \/client-component/);
assert.match(stdin, /● \/client-component\/parameter\/\[\.\.\.parameters\]/);
assert.match(stdin, /● \/client-component\/parameter\/\[parameter\]/);

assert.match(stdin, /λ \/server-component/);
assert.match(stdin, /λ \/server-component\/parameter\/\[\.\.\.parameters\]/);
assert.match(stdin, /λ \/server-component\/parameter\/\[parameter\]/);

export {};
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { TransactionContext } from './transaction-context';
import { captureException } from '@sentry/nextjs';

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

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

return (
<div>
<button
onClick={() => {
toggle();
}}
>
{transactionActive ? 'Stop Transaction' : 'Start Transaction'}
</button>
{transactionContextValue.transactionActive ? (
<button
onClick={() => {
transactionContextValue.stop();
setTransactionName('');
}}
>
Stop transaction
</button>
) : (
<>
<input
type="text"
placeholder="Transaction name"
value={transactionName}
onChange={e => {
setTransactionName(e.target.value);
}}
/>
<button
onClick={() => {
transactionContextValue.start(transactionName);
}}
>
Start transaction
</button>
</>
)}
<br />
<br />
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,36 @@ import { createContext, PropsWithChildren, useState } from 'react';
import { Transaction } from '@sentry/types';
import { startTransaction, getCurrentHub } from '@sentry/nextjs';

export const TransactionContext = createContext<{ transactionActive: boolean; toggle: () => void }>({
export const TransactionContext = createContext<
{ transactionActive: false; start: (transactionName: string) => void } | { transactionActive: true; stop: () => void }
>({
transactionActive: false,
toggle: () => undefined,
start: () => undefined,
});

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

return (
<TransactionContext.Provider
value={{
transactionActive: !!transaction,
toggle: () => {
if (transaction) {
transaction.finish();
setTransaction(undefined);
} else {
const t = startTransaction({ name: 'Manual Transaction' });
getCurrentHub().getScope()?.setSpan(t);
setTransaction(t);
}
},
}}
value={
transaction
? {
transactionActive: true,
stop: () => {
transaction.finish();
setTransaction(undefined);
},
}
: {
transactionActive: false,
start: (transactionName: string) => {
const t = startTransaction({ name: transactionName });
getCurrentHub().getScope()?.setSpan(t);
setTransaction(t);
},
}
}
>
{children}
</TransactionContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"$schema": "../../test-recipe-schema.json",
"testApplicationName": "nextjs-13-app-dir",
"buildCommand": "yarn install --pure-lockfile && npx playwright install && yarn build",
"buildAssertionCommand": "yarn ts-node --script-mode assert-build.ts",
"tests": [
{
"testName": "Prod Mode",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ test.describe('dev mode error symbolification', () => {

expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual(
expect.objectContaining({
function: 'onClick',
filename: 'components/client-error-debug-tools.tsx',
abs_path: 'webpack-internal:///(app-client)/./components/client-error-debug-tools.tsx',
function: 'onClick',
in_app: true,
lineno: 32,
lineno: 54,
colno: 16,
post_context: [' }}', ' >', ' Throw error'],
context_line: " throw new Error('Click Error');",
in_app: true,
pre_context: [' <button', ' onClick={() => {'],
context_line: " throw new Error('Click Error');",
post_context: [' }}', ' >', ' Throw error'],
}),
);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test } from '@playwright/test';
import { waitForTransaction } from '../../../test-utils/event-proxy-server';

if (process.env.TEST_ENV === 'production') {
// TODO: Fix that this is flakey on dev server - might be an SDK bug
test('Sends connected traces for server components', async ({ page }, testInfo) => {
await page.goto('/client-component');

const clientTransactionName = `e2e-next-js-app-dir: ${testInfo.title}`;

const serverComponentTransaction = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
return (
transactionEvent?.transaction === 'Page Server Component (/server-component)' &&
(await clientTransactionPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id
);
});

const clientTransactionPromise = waitForTransaction('nextjs-13-app-dir', transactionEvent => {
return transactionEvent?.transaction === clientTransactionName;
});

await page.getByPlaceholder('Transaction name').fill(clientTransactionName);
await page.getByText('Start transaction').click();
await page.getByRole('link', { name: /^\/server-component$/ }).click();
await page.getByText('Page (/server-component)').isVisible();
await page.getByText('Stop transaction').click();

await serverComponentTransaction;
});
}
4 changes: 4 additions & 0 deletions packages/e2e-tests/test-recipe-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
"type": "string",
"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."
},
"buildAssertionCommand": {
"type": "string",
"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."
},
"buildTimeoutSeconds": {
"type": "number",
"description": "Timeout for the build command in seconds. Default: 60"
Expand Down
38 changes: 27 additions & 11 deletions packages/e2e-tests/test-utils/event-proxy-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export async function startEventProxyServer(options: EventProxyServerOptions): P

export async function waitForRequest(
proxyServerName: string,
callback: (eventData: SentryRequestCallbackData) => boolean,
callback: (eventData: SentryRequestCallbackData) => Promise<boolean> | boolean,
): Promise<SentryRequestCallbackData> {
const eventCallbackServerPort = await retrieveCallbackServerPort(proxyServerName);

Expand All @@ -157,7 +157,20 @@ export async function waitForRequest(
const eventCallbackData: SentryRequestCallbackData = JSON.parse(
Buffer.from(eventContents, 'base64').toString('utf8'),
);
if (callback(eventCallbackData)) {
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);
}
Expand All @@ -175,13 +188,13 @@ export async function waitForRequest(

export function waitForEnvelopeItem(
proxyServerName: string,
callback: (envelopeItem: EnvelopeItem) => boolean,
callback: (envelopeItem: EnvelopeItem) => Promise<boolean> | boolean,
): Promise<EnvelopeItem> {
return new Promise((resolve, reject) => {
waitForRequest(proxyServerName, eventData => {
waitForRequest(proxyServerName, async eventData => {
const envelopeItems = eventData.envelope[1];
for (const envelopeItem of envelopeItems) {
if (callback(envelopeItem)) {
if (await callback(envelopeItem)) {
resolve(envelopeItem);
return true;
}
Expand All @@ -191,11 +204,14 @@ export function waitForEnvelopeItem(
});
}

export function waitForError(proxyServerName: string, callback: (transactionEvent: Event) => boolean): Promise<Event> {
export function waitForError(
proxyServerName: string,
callback: (transactionEvent: Event) => Promise<boolean> | boolean,
): Promise<Event> {
return new Promise((resolve, reject) => {
waitForEnvelopeItem(proxyServerName, envelopeItem => {
waitForEnvelopeItem(proxyServerName, async envelopeItem => {
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;
if (envelopeItemHeader.type === 'event' && callback(envelopeItemBody as Event)) {
if (envelopeItemHeader.type === 'event' && (await callback(envelopeItemBody as Event))) {
resolve(envelopeItemBody as Event);
return true;
}
Expand All @@ -206,12 +222,12 @@ export function waitForError(proxyServerName: string, callback: (transactionEven

export function waitForTransaction(
proxyServerName: string,
callback: (transactionEvent: Event) => boolean,
callback: (transactionEvent: Event) => Promise<boolean> | boolean,
): Promise<Event> {
return new Promise((resolve, reject) => {
waitForEnvelopeItem(proxyServerName, envelopeItem => {
waitForEnvelopeItem(proxyServerName, async envelopeItem => {
const [envelopeItemHeader, envelopeItemBody] = envelopeItem;
if (envelopeItemHeader.type === 'transaction' && callback(envelopeItemBody as Event)) {
if (envelopeItemHeader.type === 'transaction' && (await callback(envelopeItemBody as Event))) {
resolve(envelopeItemBody as Event);
return true;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/nextjs/src/common/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export type ServerComponentContext = {
componentRoute: string;
componentType: string;
sentryTraceHeader?: string;
baggageHeader?: string;
};
2 changes: 1 addition & 1 deletion packages/nextjs/src/config/loaders/wrappingLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export default function wrappingLoader(
// https://github.com/vercel/next.js/blob/295f9da393f7d5a49b0c2e15a2f46448dbdc3895/packages/next/build/analysis/get-page-static-info.ts#L37
// https://github.com/vercel/next.js/blob/a1c15d84d906a8adf1667332a3f0732be615afa0/packages/next-swc/crates/core/src/react_server_components.rs#L247
// We do not want to wrap client components
if (userCode.includes('/* __next_internal_client_entry_do_not_use__ */')) {
if (userCode.includes('__next_internal_client_entry_do_not_use__')) {
this.callback(null, userCode, userModuleSourceMap);
return;
}
Expand Down
Loading