Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/core/src/tools/stackTrace/handlingStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
113 changes: 113 additions & 0 deletions packages/rum-nextjs/src/domain/error/addNextjsError.spec.ts
Original file line number Diff line number Diff line change
@@ -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' }),
})
)
})
})
43 changes: 43 additions & 0 deletions packages/rum-nextjs/src/domain/error/addNextjsError.ts
Original file line number Diff line number Diff line change
@@ -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 <div>Something went wrong</div>
* }
* ```
*/
export function addNextjsError(error: Error & { digest?: string }, errorInfo?: ErrorInfo) {
Comment thread
BeltranBulbarellaDD marked this conversation as resolved.
const handlingStack = createHandlingStack('nextjs error')
const startClocks = clocksNow()
onRumStart((addError) => {
callMonitored(() => {
addError({
error,
handlingStack,
Comment thread
mormubis marked this conversation as resolved.
componentStack: errorInfo?.componentStack ?? undefined,
startClocks,
context: {
...(error as Error & { dd_context?: Context }).dd_context,
...(error.digest && { nextjs: { digest: error.digest } }),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ question: ‏Should nextjs.digest part of the rum-evens-format

framework: 'nextjs',
},
})
})
})
}
12 changes: 6 additions & 6 deletions packages/rum-nextjs/src/domain/nextjsPlugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
18 changes: 9 additions & 9 deletions packages/rum-nextjs/src/domain/nextjsPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import type { RumPlugin, RumPublicApi, StartRumResult } from '@datadog/browser-r
export type NextjsPlugin = Pick<Required<RumPlugin>, '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[] = []
Expand All @@ -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)
}
}
},
Expand Down Expand Up @@ -58,16 +58,16 @@ export function onRumInit(callback: InitSubscriber) {
}

export function onRumStart(callback: StartSubscriber) {
if (globalAddEvent) {
callback(globalAddEvent)
if (globalAddError) {
callback(globalAddError)
} else {
onRumStartSubscribers.push(callback)
}
}

export function resetNextjsPlugin() {
globalPublicApi = undefined
globalAddEvent = undefined
globalAddError = undefined
onRumInitSubscribers.length = 0
onRumStartSubscribers.length = 0
lastNavigationUrl = undefined
Expand Down
1 change: 1 addition & 0 deletions packages/rum-nextjs/src/entries/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
3 changes: 2 additions & 1 deletion test/apps/nextjs/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.next
node_modules
.yarn/*
.yarn/*
next-env.d.ts
23 changes: 23 additions & 0 deletions test/apps/nextjs/app/error-test/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div data-testid="error-boundary">
<h2>Something went wrong!</h2>
{error.digest && <p data-testid="error-digest">Digest: {error.digest}</p>}
<button onClick={reset}>Try again</button>
<br />
<Link href="/">Go to Home</Link>
</div>
)
}
24 changes: 24 additions & 0 deletions test/apps/nextjs/app/error-test/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<Link href="/">← Back to Home</Link>
<h1>Error Test</h1>
<button data-testid="trigger-error" onClick={() => setShouldThrow(true)}>
Trigger Error
</button>
</div>
)
}
16 changes: 16 additions & 0 deletions test/apps/nextjs/app/error-test/server-error/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1>Server Error Test</h1>
</div>
)
}
7 changes: 7 additions & 0 deletions test/apps/nextjs/app/global-error-test/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>Global error test page</div>
}
26 changes: 26 additions & 0 deletions test/apps/nextjs/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html>
<body>
<div data-testid="global-error-boundary">
<h2>Global error!</h2>
{error.digest && <p data-testid="error-digest">Digest: {error.digest}</p>}
<button onClick={reset}>Try again</button>
<br />
<a href="/">Go to Home</a>
</div>
</body>
</html>
)
}
9 changes: 9 additions & 0 deletions test/apps/nextjs/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ export default function HomePage() {
<li>
<Link href="/guides/123">Go to Guides 123</Link>
</li>
<li>
<Link href="/error-test">Go to Error Test</Link>
</li>
<li>
<Link href="/error-test/server-error?throw=true">Go to Server Error</Link>
</li>
<li>
<Link href="/global-error-test?throw=true">Go to Global Error</Link>
</li>
</ul>
</div>
)
Expand Down
7 changes: 0 additions & 7 deletions test/apps/nextjs/next-env.d.ts

This file was deleted.

Loading
Loading