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,