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}
}
+
Try again
+
+
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
+ setShouldThrow(true)}>
+ Trigger Error
+
+
+ )
+}
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}
}
+
Try again
+
+
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)
+ })
+ })
+ })
+})