Skip to content

Commit d67df35

Browse files
authored
feat(nextjs): Add experimental_captureRequestError for onRequestError hook (#12885)
1 parent 9f07f99 commit d67df35

File tree

10 files changed

+169
-15
lines changed

10 files changed

+169
-15
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Suspense } from 'react';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
export default async function Page() {
6+
return (
7+
<Suspense fallback={<p>Loading...</p>}>
8+
{/* @ts-ignore */}
9+
<Crash />;
10+
</Suspense>
11+
);
12+
}
13+
14+
async function Crash() {
15+
throw new Error('I am technically uncatchable');
16+
return <p>unreachable</p>;
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use client';
2+
3+
import { use } from 'react';
4+
5+
export function RenderPromise({ stringPromise }: { stringPromise: Promise<string> }) {
6+
const s = use(stringPromise);
7+
return <>{s}</>;
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Suspense } from 'react';
2+
import { RenderPromise } from './client-page';
3+
4+
export const dynamic = 'force-dynamic';
5+
6+
export default async function Page() {
7+
const crashingPromise = new Promise<string>((_, reject) => {
8+
setTimeout(() => {
9+
reject(new Error('I am a data streaming error'));
10+
}, 100);
11+
});
12+
13+
return (
14+
<Suspense fallback={<p>Loading...</p>}>
15+
<RenderPromise stringPromise={crashingPromise} />;
16+
</Suspense>
17+
);
18+
}

dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as Sentry from '@sentry/nextjs';
2+
13
export async function register() {
24
if (process.env.NEXT_RUNTIME === 'nodejs') {
35
await import('./sentry.server.config');
@@ -7,3 +9,5 @@ export async function register() {
79
await import('./sentry.edge.config');
810
}
911
}
12+
13+
export const onRequestError = Sentry.experimental_captureRequestError;

dev-packages/e2e-tests/test-applications/nextjs-15/package.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
"scripts": {
66
"build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)",
77
"clean": "npx rimraf node_modules pnpm-lock.yaml",
8-
"test:prod": "TEST_ENV=production playwright test",
9-
"test:dev": "TEST_ENV=development playwright test",
8+
"test:prod": "TEST_ENV=production __NEXT_EXPERIMENTAL_INSTRUMENTATION=1 playwright test",
9+
"test:dev": "TEST_ENV=development __NEXT_EXPERIMENTAL_INSTRUMENTATION=1 playwright test",
1010
"test:build": "pnpm install && npx playwright install && pnpm build",
1111
"test:build-canary": "pnpm install && pnpm add next@rc && pnpm add react@beta && pnpm add react-dom@beta && npx playwright install && pnpm build",
1212
"test:build-latest": "pnpm install && pnpm add next@rc && pnpm add react@beta && pnpm add react-dom@beta && npx playwright install && pnpm build",
@@ -17,7 +17,7 @@
1717
"@types/node": "18.11.17",
1818
"@types/react": "18.0.26",
1919
"@types/react-dom": "18.0.9",
20-
"next": "14.3.0-canary.73",
20+
"next": "15.0.0-canary.63",
2121
"react": "beta",
2222
"react-dom": "beta",
2323
"typescript": "4.9.5"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({
5+
page,
6+
}) => {
7+
const errorEventPromise = waitForError('nextjs-15', errorEvent => {
8+
return !!errorEvent?.exception?.values?.some(value => value.value === 'I am technically uncatchable');
9+
});
10+
11+
const serverTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
12+
return transactionEvent?.transaction === 'GET /nested-rsc-error/[param]';
13+
});
14+
15+
await page.goto(`/nested-rsc-error/123`);
16+
const errorEvent = await errorEventPromise;
17+
const serverTransactionEvent = await serverTransactionPromise;
18+
19+
// error event is part of the transaction
20+
expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id);
21+
22+
expect(errorEvent.request).toMatchObject({
23+
headers: expect.any(Object),
24+
method: 'GET',
25+
});
26+
27+
expect(errorEvent.contexts?.nextjs).toEqual({
28+
route_type: 'render',
29+
router_kind: 'App Router',
30+
router_path: '/nested-rsc-error/[param]',
31+
request_path: '/nested-rsc-error/123',
32+
});
33+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({
5+
page,
6+
}) => {
7+
const errorEventPromise = waitForError('nextjs-15', errorEvent => {
8+
return !!errorEvent?.exception?.values?.some(value => value.value === 'I am a data streaming error');
9+
});
10+
11+
const serverTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
12+
return transactionEvent?.transaction === 'GET /streaming-rsc-error/[param]';
13+
});
14+
15+
await page.goto(`/streaming-rsc-error/123`);
16+
const errorEvent = await errorEventPromise;
17+
const serverTransactionEvent = await serverTransactionPromise;
18+
19+
// error event is part of the transaction
20+
expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id);
21+
22+
expect(errorEvent.request).toMatchObject({
23+
headers: expect.any(Object),
24+
method: 'GET',
25+
});
26+
27+
expect(errorEvent.contexts?.nextjs).toEqual({
28+
route_type: 'render',
29+
router_kind: 'App Router',
30+
router_path: '/streaming-rsc-error/[param]',
31+
request_path: '/streaming-rsc-error/123',
32+
});
33+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { captureException, withScope } from '@sentry/core';
2+
3+
type RequestInfo = {
4+
url: string;
5+
method: string;
6+
headers: Record<string, string | string[] | undefined>;
7+
};
8+
9+
type ErrorContext = {
10+
routerKind: string; // 'Pages Router' | 'App Router'
11+
routePath: string;
12+
routeType: string; // 'render' | 'route' | 'middleware'
13+
};
14+
15+
/**
16+
* Reports errors for the Next.js `onRequestError` instrumentation hook.
17+
*
18+
* Notice: This function is experimental and not intended for production use. Breaking changes may be done to this funtion in any release.
19+
*
20+
* @experimental
21+
*/
22+
export function experimental_captureRequestError(
23+
error: unknown,
24+
request: RequestInfo,
25+
errorContext: ErrorContext,
26+
): void {
27+
withScope(scope => {
28+
scope.setSDKProcessingMetadata({
29+
request: {
30+
headers: request.headers,
31+
method: request.method,
32+
},
33+
});
34+
35+
scope.setContext('nextjs', {
36+
request_path: request.url,
37+
router_kind: errorContext.routerKind,
38+
router_path: errorContext.routePath,
39+
route_type: errorContext.routeType,
40+
});
41+
42+
scope.setTransactionName(errorContext.routePath);
43+
44+
captureException(error, {
45+
mechanism: {
46+
handled: false,
47+
},
48+
});
49+
});
50+
}

packages/nextjs/src/common/index.ts

+1-12
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,14 @@
11
export { wrapGetStaticPropsWithSentry } from './wrapGetStaticPropsWithSentry';
2-
32
export { wrapGetInitialPropsWithSentry } from './wrapGetInitialPropsWithSentry';
4-
53
export { wrapAppGetInitialPropsWithSentry } from './wrapAppGetInitialPropsWithSentry';
6-
74
export { wrapDocumentGetInitialPropsWithSentry } from './wrapDocumentGetInitialPropsWithSentry';
8-
95
export { wrapErrorGetInitialPropsWithSentry } from './wrapErrorGetInitialPropsWithSentry';
10-
116
export { wrapGetServerSidePropsWithSentry } from './wrapGetServerSidePropsWithSentry';
12-
137
export { wrapServerComponentWithSentry } from './wrapServerComponentWithSentry';
14-
158
export { wrapRouteHandlerWithSentry } from './wrapRouteHandlerWithSentry';
16-
179
export { wrapApiHandlerWithSentryVercelCrons } from './wrapApiHandlerWithSentryVercelCrons';
18-
1910
export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry';
20-
2111
export { wrapPageComponentWithSentry } from './wrapPageComponentWithSentry';
22-
2312
export { wrapGenerationFunctionWithSentry } from './wrapGenerationFunctionWithSentry';
24-
2513
export { withServerActionInstrumentation } from './withServerActionInstrumentation';
14+
export { experimental_captureRequestError } from './captureRequestError';

packages/nextjs/src/index.types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,5 @@ export declare function wrapApiHandlerWithSentryVercelCrons<F extends (...args:
140140
* Wraps a page component with Sentry error instrumentation.
141141
*/
142142
export declare function wrapPageComponentWithSentry<C>(WrappingTarget: C): C;
143+
144+
export { experimental_captureRequestError } from './common/captureRequestError';

0 commit comments

Comments
 (0)