From 8e2154b8e1014db3e4c683c11e154ec53aec1f70 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Thu, 11 Jul 2024 15:32:38 +0000 Subject: [PATCH 1/3] feat(nextjs): Add `experimental_captureRequestError` --- .../nextjs/src/common/captureRequestError.ts | 46 +++++++++++++++++++ packages/nextjs/src/common/index.ts | 13 +----- packages/nextjs/src/index.types.ts | 2 + 3 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 packages/nextjs/src/common/captureRequestError.ts diff --git a/packages/nextjs/src/common/captureRequestError.ts b/packages/nextjs/src/common/captureRequestError.ts new file mode 100644 index 000000000000..9950b3001546 --- /dev/null +++ b/packages/nextjs/src/common/captureRequestError.ts @@ -0,0 +1,46 @@ +import { captureException, withScope } from '@sentry/core'; + +type RequestInfo = { + url: string; + method: string; + headers: Record; +}; + +type ErrorContext = { + routerKind: string; // 'Pages Router' | 'App Router' + routePath: string; + routeType: string; // 'render' | 'route' | 'middleware' +}; + +/** + * Reports error for the Next.js `onRequestError` instrumentation hook. + */ +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, + }, + }); + }); +} diff --git a/packages/nextjs/src/common/index.ts b/packages/nextjs/src/common/index.ts index e308537f1358..23ddfa383772 100644 --- a/packages/nextjs/src/common/index.ts +++ b/packages/nextjs/src/common/index.ts @@ -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'; diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index afff0bd98a19..b093968bdebe 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -140,3 +140,5 @@ export declare function wrapApiHandlerWithSentryVercelCrons(WrappingTarget: C): C; + +export { experimental_captureRequestError } from './common/captureRequestError'; From a2aa6fb704b4ab2f08cacc7c69184c2b405843c4 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 12 Jul 2024 08:24:35 +0000 Subject: [PATCH 2/3] Add tests and experimental notice --- .../app/nested-rsc-error/[param]/page.tsx | 17 ++++++++++ .../[param]/client-page.tsx | 8 +++++ .../app/streaming-rsc-error/[param]/page.tsx | 18 ++++++++++ .../nextjs-15/instrumentation.ts | 4 +++ .../test-applications/nextjs-15/package.json | 6 ++-- .../nextjs-15/tests/nested-rsc-error.test.ts | 33 +++++++++++++++++++ .../tests/streaming-rsc-error.test.ts | 33 +++++++++++++++++++ .../nextjs/src/common/captureRequestError.ts | 4 ++- 8 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/nested-rsc-error/[param]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/streaming-rsc-error/[param]/client-page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/streaming-rsc-error/[param]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/nested-rsc-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/streaming-rsc-error.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/nested-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/nested-rsc-error/[param]/page.tsx new file mode 100644 index 000000000000..675b248026be --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/nested-rsc-error/[param]/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + return ( + Loading...

}> + {/* @ts-ignore */} + ; +
+ ); +} + +async function Crash() { + throw new Error('I am technically uncatchable'); + return

unreachable

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/streaming-rsc-error/[param]/client-page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/streaming-rsc-error/[param]/client-page.tsx new file mode 100644 index 000000000000..7b66c3fbdeef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/streaming-rsc-error/[param]/client-page.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { use } from 'react'; + +export function RenderPromise({ stringPromise }: { stringPromise: Promise }) { + const s = use(stringPromise); + return <>{s}; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/streaming-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/streaming-rsc-error/[param]/page.tsx new file mode 100644 index 000000000000..9531f9a42139 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/streaming-rsc-error/[param]/page.tsx @@ -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((_, reject) => { + setTimeout(() => { + reject(new Error('I am a data streaming error')); + }, 100); + }); + + return ( + Loading...

}> + ; +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts index 7b89a972e157..ca4a213e58ba 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation.ts @@ -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'); @@ -7,3 +9,5 @@ export async function register() { await import('./sentry.edge.config'); } } + +export const onRequestError = Sentry.experimental_captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index ebd18c6fb10e..4c3f56b0aa0c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -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", @@ -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" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/nested-rsc-error.test.ts new file mode 100644 index 000000000000..223da5b245e9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/nested-rsc-error.test.ts @@ -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', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/streaming-rsc-error.test.ts new file mode 100644 index 000000000000..b50e9688861e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/streaming-rsc-error.test.ts @@ -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', + }); +}); diff --git a/packages/nextjs/src/common/captureRequestError.ts b/packages/nextjs/src/common/captureRequestError.ts index 9950b3001546..451d1464afad 100644 --- a/packages/nextjs/src/common/captureRequestError.ts +++ b/packages/nextjs/src/common/captureRequestError.ts @@ -13,7 +13,9 @@ type ErrorContext = { }; /** - * Reports error for the Next.js `onRequestError` instrumentation hook. + * 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. */ export function experimental_captureRequestError( error: unknown, From 73f774f4ba32ef6afe62cf4ba933a8c72ee7bf88 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 12 Jul 2024 08:34:24 +0000 Subject: [PATCH 3/3] add experimental tag --- packages/nextjs/src/common/captureRequestError.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/nextjs/src/common/captureRequestError.ts b/packages/nextjs/src/common/captureRequestError.ts index 451d1464afad..7968907ad9bf 100644 --- a/packages/nextjs/src/common/captureRequestError.ts +++ b/packages/nextjs/src/common/captureRequestError.ts @@ -16,6 +16,8 @@ type ErrorContext = { * 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,