Skip to content

Commit

Permalink
feat(nextjs): Add experimental_captureRequestError for `onRequestEr…
Browse files Browse the repository at this point in the history
…ror` hook (#12885)
  • Loading branch information
lforst authored Jul 12, 2024
1 parent 9f07f99 commit d67df35
Show file tree
Hide file tree
Showing 10 changed files with 169 additions and 15 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Suspense } from 'react';

export const dynamic = 'force-dynamic';

export default async function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
{/* @ts-ignore */}
<Crash />;
</Suspense>
);
}

async function Crash() {
throw new Error('I am technically uncatchable');
return <p>unreachable</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use client';

import { use } from 'react';

export function RenderPromise({ stringPromise }: { stringPromise: Promise<string> }) {
const s = use(stringPromise);
return <>{s}</>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Suspense } from 'react';
import { RenderPromise } from './client-page';

export const dynamic = 'force-dynamic';

export default async function Page() {
const crashingPromise = new Promise<string>((_, reject) => {
setTimeout(() => {
reject(new Error('I am a data streaming error'));
}, 100);
});

return (
<Suspense fallback={<p>Loading...</p>}>
<RenderPromise stringPromise={crashingPromise} />;
</Suspense>
);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as Sentry from '@sentry/nextjs';

export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
Expand All @@ -7,3 +9,5 @@ export async function register() {
await import('./sentry.edge.config');
}
}

export const onRequestError = Sentry.experimental_captureRequestError;
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"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:prod": "TEST_ENV=production __NEXT_EXPERIMENTAL_INSTRUMENTATION=1 playwright test",
"test:dev": "TEST_ENV=development __NEXT_EXPERIMENTAL_INSTRUMENTATION=1 playwright test",
"test:build": "pnpm install && npx playwright install && pnpm build",
"test:build-canary": "pnpm install && pnpm add next@rc && pnpm add react@beta && pnpm add react-dom@beta && npx playwright install && pnpm build",
"test:build-latest": "pnpm install && pnpm add next@rc && pnpm add react@beta && pnpm add react-dom@beta && npx playwright install && pnpm build",
Expand All @@ -17,7 +17,7 @@
"@types/node": "18.11.17",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.9",
"next": "14.3.0-canary.73",
"next": "15.0.0-canary.63",
"react": "beta",
"react-dom": "beta",
"typescript": "4.9.5"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';

test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({
page,
}) => {
const errorEventPromise = waitForError('nextjs-15', errorEvent => {
return !!errorEvent?.exception?.values?.some(value => value.value === 'I am technically uncatchable');
});

const serverTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
return transactionEvent?.transaction === 'GET /nested-rsc-error/[param]';
});

await page.goto(`/nested-rsc-error/123`);
const errorEvent = await errorEventPromise;
const serverTransactionEvent = await serverTransactionPromise;

// error event is part of the transaction
expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id);

expect(errorEvent.request).toMatchObject({
headers: expect.any(Object),
method: 'GET',
});

expect(errorEvent.contexts?.nextjs).toEqual({
route_type: 'render',
router_kind: 'App Router',
router_path: '/nested-rsc-error/[param]',
request_path: '/nested-rsc-error/123',
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';

test('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({
page,
}) => {
const errorEventPromise = waitForError('nextjs-15', errorEvent => {
return !!errorEvent?.exception?.values?.some(value => value.value === 'I am a data streaming error');
});

const serverTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
return transactionEvent?.transaction === 'GET /streaming-rsc-error/[param]';
});

await page.goto(`/streaming-rsc-error/123`);
const errorEvent = await errorEventPromise;
const serverTransactionEvent = await serverTransactionPromise;

// error event is part of the transaction
expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id);

expect(errorEvent.request).toMatchObject({
headers: expect.any(Object),
method: 'GET',
});

expect(errorEvent.contexts?.nextjs).toEqual({
route_type: 'render',
router_kind: 'App Router',
router_path: '/streaming-rsc-error/[param]',
request_path: '/streaming-rsc-error/123',
});
});
50 changes: 50 additions & 0 deletions packages/nextjs/src/common/captureRequestError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { captureException, withScope } from '@sentry/core';

type RequestInfo = {
url: string;
method: string;
headers: Record<string, string | string[] | undefined>;
};

type ErrorContext = {
routerKind: string; // 'Pages Router' | 'App Router'
routePath: string;
routeType: string; // 'render' | 'route' | 'middleware'
};

/**
* Reports errors for the Next.js `onRequestError` instrumentation hook.
*
* Notice: This function is experimental and not intended for production use. Breaking changes may be done to this funtion in any release.
*
* @experimental
*/
export function experimental_captureRequestError(
error: unknown,
request: RequestInfo,
errorContext: ErrorContext,
): void {
withScope(scope => {
scope.setSDKProcessingMetadata({
request: {
headers: request.headers,
method: request.method,
},
});

scope.setContext('nextjs', {
request_path: request.url,
router_kind: errorContext.routerKind,
router_path: errorContext.routePath,
route_type: errorContext.routeType,
});

scope.setTransactionName(errorContext.routePath);

captureException(error, {
mechanism: {
handled: false,
},
});
});
}
13 changes: 1 addition & 12 deletions packages/nextjs/src/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,14 @@
export { wrapGetStaticPropsWithSentry } from './wrapGetStaticPropsWithSentry';

export { wrapGetInitialPropsWithSentry } from './wrapGetInitialPropsWithSentry';

export { wrapAppGetInitialPropsWithSentry } from './wrapAppGetInitialPropsWithSentry';

export { wrapDocumentGetInitialPropsWithSentry } from './wrapDocumentGetInitialPropsWithSentry';

export { wrapErrorGetInitialPropsWithSentry } from './wrapErrorGetInitialPropsWithSentry';

export { wrapGetServerSidePropsWithSentry } from './wrapGetServerSidePropsWithSentry';

export { wrapServerComponentWithSentry } from './wrapServerComponentWithSentry';

export { wrapRouteHandlerWithSentry } from './wrapRouteHandlerWithSentry';

export { wrapApiHandlerWithSentryVercelCrons } from './wrapApiHandlerWithSentryVercelCrons';

export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry';

export { wrapPageComponentWithSentry } from './wrapPageComponentWithSentry';

export { wrapGenerationFunctionWithSentry } from './wrapGenerationFunctionWithSentry';

export { withServerActionInstrumentation } from './withServerActionInstrumentation';
export { experimental_captureRequestError } from './captureRequestError';
2 changes: 2 additions & 0 deletions packages/nextjs/src/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,5 @@ export declare function wrapApiHandlerWithSentryVercelCrons<F extends (...args:
* Wraps a page component with Sentry error instrumentation.
*/
export declare function wrapPageComponentWithSentry<C>(WrappingTarget: C): C;

export { experimental_captureRequestError } from './common/captureRequestError';

0 comments on commit d67df35

Please sign in to comment.