diff --git a/packages/core/src/tools/stackTrace/handlingStack.ts b/packages/core/src/tools/stackTrace/handlingStack.ts index b7a6f54186..d5cacd6d68 100644 --- a/packages/core/src/tools/stackTrace/handlingStack.ts +++ b/packages/core/src/tools/stackTrace/handlingStack.ts @@ -9,7 +9,16 @@ import { computeStackTrace } from './computeStackTrace' * - No monitored function should encapsulate it, that is why we need to use callMonitored inside it. */ export function createHandlingStack( - type: 'console error' | 'action' | 'error' | 'instrumented method' | 'log' | 'react error' | 'view' | 'vital' + type: + | 'console error' + | 'action' + | 'error' + | 'instrumented method' + | 'log' + | 'react error' + | 'view' + | 'vital' + | 'nextjs error' ): string { /** * Skip the two internal frames: diff --git a/packages/rum-nextjs/src/domain/error/addNextjsError.spec.ts b/packages/rum-nextjs/src/domain/error/addNextjsError.spec.ts new file mode 100644 index 0000000000..0f3b730b65 --- /dev/null +++ b/packages/rum-nextjs/src/domain/error/addNextjsError.spec.ts @@ -0,0 +1,113 @@ +import type { RumInitConfiguration, RumPublicApi } from '@datadog/browser-rum-core' +import { registerCleanupTask } from '@datadog/browser-core/test' +import { nextjsPlugin, resetNextjsPlugin } from '../nextjsPlugin' +import { addNextjsError } from './addNextjsError' + +const INIT_CONFIGURATION = {} as RumInitConfiguration + +function initializeNextjsPlugin() { + const addErrorSpy = jasmine.createSpy() + const publicApi = { startView: jasmine.createSpy() } as unknown as RumPublicApi + const plugin = nextjsPlugin() + plugin.onInit({ publicApi, initConfiguration: { ...INIT_CONFIGURATION } }) + plugin.onRumStart({ addError: addErrorSpy }) + registerCleanupTask(() => { + resetNextjsPlugin() + }) + return { addErrorSpy } +} + +describe('addNextjsError', () => { + it('does nothing when the plugin is not initialized', () => { + registerCleanupTask(() => { + resetNextjsPlugin() + }) + expect(() => addNextjsError(new Error('test'))).not.toThrow() + }) + + it('delegates the error to addError', () => { + const { addErrorSpy } = initializeNextjsPlugin() + const originalError = new Error('test error') + + addNextjsError(originalError, { componentStack: 'at ComponentSpy toto.js' }) + + expect(addErrorSpy).toHaveBeenCalledOnceWith({ + error: originalError, + handlingStack: jasmine.any(String), + componentStack: 'at ComponentSpy toto.js', + startClocks: jasmine.any(Object), + context: { framework: 'nextjs' }, + }) + }) + + it('merges dd_context from the original error with nextjs error context', () => { + const { addErrorSpy } = initializeNextjsPlugin() + const originalError = new Error('error message') + ;(originalError as any).dd_context = { component: 'Menu', param: 123 } + + addNextjsError(originalError, {}) + + expect(addErrorSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + error: originalError, + context: { + framework: 'nextjs', + component: 'Menu', + param: 123, + }, + }) + ) + }) + + it('adds nextjs.digest context when error.digest is present', () => { + const { addErrorSpy } = initializeNextjsPlugin() + const error = Object.assign(new Error('server error'), { digest: 'abc123' }) + + addNextjsError(error, {}) + + expect(addErrorSpy.calls.mostRecent().args[0]).toEqual( + jasmine.objectContaining({ + context: jasmine.objectContaining({ framework: 'nextjs', nextjs: { digest: 'abc123' } }), + }) + ) + }) + + it('omits nextjs key when digest is undefined', () => { + const { addErrorSpy } = initializeNextjsPlugin() + const error = new Error('client error') + + addNextjsError(error) + + expect(addErrorSpy.calls.mostRecent().args[0]).toEqual( + jasmine.objectContaining({ + context: { framework: 'nextjs' }, + }) + ) + }) + + it('omits componentStack when errorInfo is missing', () => { + const { addErrorSpy } = initializeNextjsPlugin() + const error = new Error('client error') + + addNextjsError(error) + + expect(addErrorSpy.calls.mostRecent().args[0]).toEqual( + jasmine.objectContaining({ + componentStack: undefined, + }) + ) + }) + + it('does not let error.dd_context overwrite framework', () => { + const { addErrorSpy } = initializeNextjsPlugin() + const error = Object.assign(new Error('test error'), { dd_context: { framework: 'from-dd-context' } }) + + addNextjsError(error, {}) + + expect(addErrorSpy.calls.mostRecent().args[0]).toEqual( + jasmine.objectContaining({ + context: jasmine.objectContaining({ framework: 'nextjs' }), + }) + ) + }) +}) diff --git a/packages/rum-nextjs/src/domain/error/addNextjsError.ts b/packages/rum-nextjs/src/domain/error/addNextjsError.ts new file mode 100644 index 0000000000..7fc47c016d --- /dev/null +++ b/packages/rum-nextjs/src/domain/error/addNextjsError.ts @@ -0,0 +1,43 @@ +import { callMonitored, clocksNow, createHandlingStack } from '@datadog/browser-core' +import type { Context } from '@datadog/browser-core' +import type { ErrorInfo } from 'react' +import { onRumStart } from '../nextjsPlugin' + +/** + * Add a Next.js error to the RUM session. + * + * @category Error + * @example + * ```ts + * // app/error.tsx (or app/global-error.tsx) + * 'use client' + * import { useEffect } from 'react' + * import { addNextjsError } from '@datadog/browser-rum-nextjs' + * + * export default function Error({ error }: { error: Error & { digest?: string } }) { + * useEffect(() => { + * addNextjsError(error) + * }, [error]) + * return
Something went wrong
+ * } + * ``` + */ +export function addNextjsError(error: Error & { digest?: string }, errorInfo?: ErrorInfo) { + const handlingStack = createHandlingStack('nextjs error') + const startClocks = clocksNow() + onRumStart((addError) => { + callMonitored(() => { + addError({ + error, + handlingStack, + componentStack: errorInfo?.componentStack ?? undefined, + startClocks, + context: { + ...(error as Error & { dd_context?: Context }).dd_context, + ...(error.digest && { nextjs: { digest: error.digest } }), + framework: 'nextjs', + }, + }) + }) + }) +} diff --git a/packages/rum-nextjs/src/domain/nextjsPlugin.spec.ts b/packages/rum-nextjs/src/domain/nextjsPlugin.spec.ts index 5d007e6be4..7145e13a60 100644 --- a/packages/rum-nextjs/src/domain/nextjsPlugin.spec.ts +++ b/packages/rum-nextjs/src/domain/nextjsPlugin.spec.ts @@ -121,24 +121,24 @@ describe('nextjsPlugin', () => { it('calls onRumStart subscribers during onRumStart', () => { const callbackSpy = jasmine.createSpy() - const mockAddEvent = jasmine.createSpy() + const mockAddError = jasmine.createSpy() onRumStart(callbackSpy) const { plugin } = initPlugin() - plugin.onRumStart({ addEvent: mockAddEvent }) + plugin.onRumStart({ addError: mockAddError }) - expect(callbackSpy).toHaveBeenCalledWith(mockAddEvent) + expect(callbackSpy).toHaveBeenCalledWith(mockAddError) }) it('calls onRumStart subscriber immediately if already started', () => { - const mockAddEvent = jasmine.createSpy() + const mockAddError = jasmine.createSpy() const { plugin } = initPlugin() - plugin.onRumStart({ addEvent: mockAddEvent }) + plugin.onRumStart({ addError: mockAddError }) const callbackSpy = jasmine.createSpy() onRumStart(callbackSpy) - expect(callbackSpy).toHaveBeenCalledWith(mockAddEvent) + expect(callbackSpy).toHaveBeenCalledWith(mockAddError) }) }) }) diff --git a/packages/rum-nextjs/src/domain/nextjsPlugin.ts b/packages/rum-nextjs/src/domain/nextjsPlugin.ts index 4e485e3052..8f8d21dc82 100644 --- a/packages/rum-nextjs/src/domain/nextjsPlugin.ts +++ b/packages/rum-nextjs/src/domain/nextjsPlugin.ts @@ -4,10 +4,10 @@ import type { RumPlugin, RumPublicApi, StartRumResult } from '@datadog/browser-r export type NextjsPlugin = Pick, 'name' | 'onInit' | 'onRumStart'> type InitSubscriber = (rumPublicApi: RumPublicApi) => void -type StartSubscriber = (addEvent: StartRumResult['addEvent']) => void +type StartSubscriber = (addError: StartRumResult['addError']) => void let globalPublicApi: RumPublicApi | undefined -let globalAddEvent: StartRumResult['addEvent'] | undefined +let globalAddError: StartRumResult['addError'] | undefined let lastNavigationUrl: string | undefined const onRumInitSubscribers: InitSubscriber[] = [] @@ -24,11 +24,11 @@ export function nextjsPlugin(): NextjsPlugin { subscriber(publicApi) } }, - onRumStart({ addEvent }) { - globalAddEvent = addEvent - if (addEvent) { + onRumStart({ addError }) { + globalAddError = addError + if (addError) { for (const subscriber of onRumStartSubscribers) { - subscriber(addEvent) + subscriber(addError) } } }, @@ -58,8 +58,8 @@ export function onRumInit(callback: InitSubscriber) { } export function onRumStart(callback: StartSubscriber) { - if (globalAddEvent) { - callback(globalAddEvent) + if (globalAddError) { + callback(globalAddError) } else { onRumStartSubscribers.push(callback) } @@ -67,7 +67,7 @@ export function onRumStart(callback: StartSubscriber) { export function resetNextjsPlugin() { globalPublicApi = undefined - globalAddEvent = undefined + globalAddError = undefined onRumInitSubscribers.length = 0 onRumStartSubscribers.length = 0 lastNavigationUrl = undefined diff --git a/packages/rum-nextjs/src/entries/main.ts b/packages/rum-nextjs/src/entries/main.ts index 7b2612ef9a..d6bdb2237f 100644 --- a/packages/rum-nextjs/src/entries/main.ts +++ b/packages/rum-nextjs/src/entries/main.ts @@ -2,3 +2,4 @@ export { nextjsPlugin, onRouterTransitionStart } from '../domain/nextjsPlugin' export type { NextjsPlugin } from '../domain/nextjsPlugin' export { DatadogAppRouter } from '../domain/nextJSRouter/datadogAppRouter' export { DatadogPagesRouter } from '../domain/nextJSRouter/datadogPagesRouter' +export { addNextjsError } from '../domain/error/addNextjsError' diff --git a/test/apps/nextjs/.gitignore b/test/apps/nextjs/.gitignore index 20e9477154..242f8e1856 100644 --- a/test/apps/nextjs/.gitignore +++ b/test/apps/nextjs/.gitignore @@ -1,3 +1,4 @@ .next node_modules -.yarn/* \ No newline at end of file +.yarn/* +next-env.d.ts \ No newline at end of file diff --git a/test/apps/nextjs/app/error-test/error.tsx b/test/apps/nextjs/app/error-test/error.tsx new file mode 100644 index 0000000000..95e5f41353 --- /dev/null +++ b/test/apps/nextjs/app/error-test/error.tsx @@ -0,0 +1,23 @@ +// Segment-level error boundary for the error-test route (https://nextjs.org/docs/app/api-reference/file-conventions/error). +// Calls addNextjsError to report both client errors and server errors (with digest) to RUM. +'use client' + +import { addNextjsError } from '@datadog/browser-rum-nextjs' +import Link from 'next/link' +import { useEffect } from 'react' + +export default function ErrorBoundary({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + addNextjsError(error) + }, [error]) + + return ( +
+

Something went wrong!

+ {error.digest &&

Digest: {error.digest}

} + +
+ Go to Home +
+ ) +} diff --git a/test/apps/nextjs/app/error-test/page.tsx b/test/apps/nextjs/app/error-test/page.tsx new file mode 100644 index 0000000000..70b1a7de53 --- /dev/null +++ b/test/apps/nextjs/app/error-test/page.tsx @@ -0,0 +1,24 @@ +// Error test page. Triggers a client-side error +// and verifies it is captured by error.tsx via addNextjsError. +'use client' + +import { useState } from 'react' +import Link from 'next/link' + +export default function ErrorTestPage() { + const [shouldThrow, setShouldThrow] = useState(false) + + if (shouldThrow) { + throw new Error('Client error from error-test') + } + + return ( +
+ ← Back to Home +

Error Test

+ +
+ ) +} diff --git a/test/apps/nextjs/app/error-test/server-error/page.tsx b/test/apps/nextjs/app/error-test/server-error/page.tsx new file mode 100644 index 0000000000..153c2c9273 --- /dev/null +++ b/test/apps/nextjs/app/error-test/server-error/page.tsx @@ -0,0 +1,16 @@ +// Server component that throws when ?throw=true is present. Used to verify that server errors +// are caught by the parent error.tsx with a digest attached to the error context. +// The ?throw=true guard prevents Next.js from failing the build during static prerendering. +export default async function ServerErrorPage({ searchParams }: { searchParams: Promise<{ throw?: string }> }) { + const { throw: shouldThrow } = await searchParams + + if (shouldThrow === 'true') { + throw new Error('Server error from error-test') + } + + return ( +
+

Server Error Test

+
+ ) +} diff --git a/test/apps/nextjs/app/global-error-test/page.tsx b/test/apps/nextjs/app/global-error-test/page.tsx new file mode 100644 index 0000000000..d0a2c1c6b3 --- /dev/null +++ b/test/apps/nextjs/app/global-error-test/page.tsx @@ -0,0 +1,7 @@ +// Server component that throws at root level when ?throw=true, triggering global-error.tsx. +// Needed because global-error.tsx only activates for errors that escape the root layout. +export default async function GlobalErrorTestPage({ searchParams }: { searchParams: Promise<{ throw?: string }> }) { + const { throw: shouldThrow } = await searchParams + if (shouldThrow === 'true') throw new Error('Global error test') + return
Global error test page
+} diff --git a/test/apps/nextjs/app/global-error.tsx b/test/apps/nextjs/app/global-error.tsx new file mode 100644 index 0000000000..b4976d9c86 --- /dev/null +++ b/test/apps/nextjs/app/global-error.tsx @@ -0,0 +1,26 @@ +// Global error boundary — catches errors that escape the root layout (https://nextjs.org/docs/app/getting-started/error-handling#global-errors). +// Calls addNextjsError so tests can verify RUM captures root-level errors. +'use client' + +import { addNextjsError } from '@datadog/browser-rum-nextjs' +import { useEffect } from 'react' + +export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + addNextjsError(error) + }, [error]) + + return ( + + +
+

Global error!

+ {error.digest &&

Digest: {error.digest}

} + +
+ Go to Home +
+ + + ) +} diff --git a/test/apps/nextjs/app/page.tsx b/test/apps/nextjs/app/page.tsx index f0497463c9..9d80fd5b82 100644 --- a/test/apps/nextjs/app/page.tsx +++ b/test/apps/nextjs/app/page.tsx @@ -11,6 +11,15 @@ export default function HomePage() {
  • Go to Guides 123
  • +
  • + Go to Error Test +
  • +
  • + Go to Server Error +
  • +
  • + Go to Global Error +
  • ) diff --git a/test/apps/nextjs/next-env.d.ts b/test/apps/nextjs/next-env.d.ts deleted file mode 100644 index 2d5420ebae..0000000000 --- a/test/apps/nextjs/next-env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// -/// -/// -import "./.next/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/test/apps/nextjs/yarn.lock b/test/apps/nextjs/yarn.lock index 3471bae4d3..dbd312fc16 100644 --- a/test/apps/nextjs/yarn.lock +++ b/test/apps/nextjs/yarn.lock @@ -7,8 +7,8 @@ __metadata: "@datadog/browser-core@file:../../../packages/core/package.tgz::locator=nextjs%40workspace%3A.": version: 6.30.1 - resolution: "@datadog/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=0e4c37&locator=nextjs%40workspace%3A." - checksum: 10c0/9973a9b8d941b51d72f636e4873924f3a3ca563038b49ab3931cfaee2b16dd547b4bc832082388d6bb842ea107f52ffd383bdda91f96c7ba0d98668b1924db72 + resolution: "@datadog/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=938bcb&locator=nextjs%40workspace%3A." + checksum: 10c0/57c9e38cc77d551614bd166ebbe2c0dd1048866377d0a325a0a486f6a12c99468e570c64ed39dfcd314a2f2895a76d413178a07f8d4f8c5b7226aaf332191b6e languageName: node linkType: hard @@ -23,7 +23,7 @@ __metadata: "@datadog/browser-rum-nextjs@file:../../../packages/rum-nextjs/package.tgz::locator=nextjs%40workspace%3A.": version: 6.30.1 - resolution: "@datadog/browser-rum-nextjs@file:../../../packages/rum-nextjs/package.tgz#../../../packages/rum-nextjs/package.tgz::hash=8685bd&locator=nextjs%40workspace%3A." + resolution: "@datadog/browser-rum-nextjs@file:../../../packages/rum-nextjs/package.tgz#../../../packages/rum-nextjs/package.tgz::hash=5da15c&locator=nextjs%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.30.1" "@datadog/browser-rum-core": "npm:6.30.1" @@ -35,13 +35,13 @@ __metadata: optional: true react: optional: true - checksum: 10c0/28286327d887526c14118dd585e8bdfee32ee604d3115a00bde733e09b56ad37d6b1e331bc50022ebffd103071983c7eba5ffef81cd4d4f2af12bdacd9eddeb6 + checksum: 10c0/f60b0d7d24501bcc9c10b3d37abc5db4cf54e0f676dae8a2377033ac0dce93b98acd565b0fb9e0138092097b861277f677c5c9104b56cf1cbc825c3d3b2547c9 languageName: node linkType: hard "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=nextjs%40workspace%3A.": version: 6.30.1 - resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=664f84&locator=nextjs%40workspace%3A." + resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=c7f83a&locator=nextjs%40workspace%3A." dependencies: "@datadog/browser-core": "npm:6.30.1" "@datadog/browser-rum-core": "npm:6.30.1" @@ -60,7 +60,7 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/2d3f22c18973672fde7f61e0db601d01eb73fbffab117cbdf9fbbc2206d0de9ff608fa6c2d96b359586175b8347de2bfa21269d0f162371ce069667683adda4e + checksum: 10c0/996c165116a29c3a7fad114865b72621f5501d062a5343f34a60d22ce52fad6235759eff64906d4bd9b3f25519f53d30b777f697de9b732235a02273fbeca194 languageName: node linkType: hard diff --git a/test/e2e/scenario/nextjsPlugin.scenario.ts b/test/e2e/scenario/nextjsPlugin.scenario.ts index f27924f9c2..0f718e0560 100644 --- a/test/e2e/scenario/nextjsPlugin.scenario.ts +++ b/test/e2e/scenario/nextjsPlugin.scenario.ts @@ -7,6 +7,7 @@ const routerConfigs = [ router: 'app' as const, viewPrefix: '', homeUrlPattern: '**/', + clientErrorMessage: 'Client error from error-test', }, { name: 'nextjs pages router', @@ -197,3 +198,71 @@ test.describe('nextjs - router', () => { }) }) }) + +test.describe('nextjs - errors', () => { + const { name, viewPrefix, clientErrorMessage, router } = routerConfigs[0] + + test.describe(name, () => { + createTest('should report client-side error') + .withRum() + .withNextjsApp(router) + .run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs }) => { + await page.click('text=Go to Error Test') + await page.waitForURL(`**${viewPrefix}/error-test`) + + await page.click('[data-testid="trigger-error"]') + await page.waitForSelector('[data-testid="error-boundary"]') + + await flushEvents() + + // React StrictMode double-fires useEffect in dev mode, so we may get 2 errors + const customErrors = intakeRegistry.rumErrorEvents.filter((e) => e.error.source === 'custom') + expect(customErrors.length).toBeGreaterThanOrEqual(1) + expect(customErrors[0].error.message).toBe(clientErrorMessage) + expect(customErrors[0].error.handling_stack).toBeDefined() + + withBrowserLogs((browserLogs) => { + expect(browserLogs.length).toBeGreaterThan(0) + }) + }) + + createTest('should report a server error with digest via addNextjsError') + .withRum() + .withNextjsApp(router) + .run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs }) => { + await page.click('text=Go to Server Error') + await page.waitForSelector('[data-testid="error-boundary"]') + + await flushEvents() + + // React StrictMode double-fires useEffect in dev mode, so we may get 2 errors + const customErrors = intakeRegistry.rumErrorEvents.filter((e) => e.error.source === 'custom') + expect(customErrors.length).toBeGreaterThanOrEqual(1) + expect(customErrors[0].error.handling_stack).toBeDefined() + expect((customErrors[0].context?.nextjs as { digest: string }).digest).toBeDefined() + + withBrowserLogs((browserLogs) => { + expect(browserLogs.length).toBeGreaterThan(0) + }) + }) + + createTest('should report global error via global-error.tsx') + .withRum() + .withNextjsApp(router) + .run(async ({ page, flushEvents, intakeRegistry, withBrowserLogs }) => { + await page.click('text=Go to Global Error') + await page.waitForSelector('[data-testid="global-error-boundary"]') + + await flushEvents() + + // React StrictMode double-fires useEffect in dev mode, so we may get 2 errors + const customErrors = intakeRegistry.rumErrorEvents.filter((e) => e.error.source === 'custom') + expect(customErrors.length).toBeGreaterThanOrEqual(1) + expect(customErrors[0].error.handling_stack).toBeDefined() + + withBrowserLogs((browserLogs) => { + expect(browserLogs.length).toBeGreaterThan(0) + }) + }) + }) +})