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 new file mode 100644 index 000000000000..7968907ad9bf --- /dev/null +++ b/packages/nextjs/src/common/captureRequestError.ts @@ -0,0 +1,50 @@ +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 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, + }, + }); + }); +} 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';