Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ playwright-report/
# Claude Code local files
*.local.md
.claude/settings.local.json

# Rum AI Toolkit
.rum-ai-toolkit/
1 change: 1 addition & 0 deletions LICENSE-3rdparty.csv
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ dev,karma-spec-reporter,MIT,Copyright 2015 Michael Lex
dev,karma-webpack,MIT,Copyright JS Foundation and other contributors
dev,lerna,MIT,Copyright 2015-present Lerna Contributors
dev,minimatch,ISC,Copyright (c) Isaac Z. Schlueter and Contributors
dev,next,MIT,Copyright (c) 2025 Vercel, Inc.
dev,node-forge,BSD,Copyright (c) 2010, Digital Bazaar, Inc.
dev,pako,MIT,(C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin
dev,prettier,MIT,Copyright James Long and contributors
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/instrumentMethod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ describe('instrumentSetter', () => {

it('does not use the Zone.js setTimeout function', () => {
const zoneJsSetTimeoutSpy = jasmine.createSpy()
zoneJs.replaceProperty(window, 'setTimeout', zoneJsSetTimeoutSpy)
zoneJs.replaceProperty(window, 'setTimeout', zoneJsSetTimeoutSpy as unknown as typeof window.setTimeout)

const object = {} as { foo: number }
Object.defineProperty(object, 'foo', { set: noop, configurable: true })
Expand Down
2 changes: 1 addition & 1 deletion packages/rum-core/src/domain/tracing/identifier.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('toPaddedHexadecimalString', () => {
})

function mockRandomValues(cb: (buffer: Uint8Array) => void) {
spyOn(window.crypto, 'getRandomValues').and.callFake((bufferView) => {
spyOn(window.crypto, 'getRandomValues').and.callFake((bufferView: ArrayBufferView) => {
cb(new Uint8Array(bufferView.buffer))
return bufferView
})
Expand Down
7 changes: 7 additions & 0 deletions packages/rum-nextjs/app-router/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@datadog/browser-rum-nextjs/app-router",
"private": true,
"main": "../cjs/entries/appRouter.js",
"module": "../esm/entries/appRouter.js",
"types": "../cjs/entries/appRouter.d.ts"
}
43 changes: 43 additions & 0 deletions packages/rum-nextjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@datadog/browser-rum-nextjs",
"license": "Apache-2.0",
"main": "cjs/entries/main.js",
"module": "esm/entries/main.js",
"types": "cjs/entries/main.d.ts",
"scripts": {
"build": "node ../../scripts/build/build-package.ts --modules",
"prepack": "npm run build"
},
"dependencies": {
"@datadog/browser-core": "6.27.1",
"@datadog/browser-rum-core": "6.27.1"
},
"peerDependencies": {
"next": ">=12.0.0",
"react": ">=18.0.0"
},
"peerDependenciesMeta": {
"next": {
"optional": true
},
"react": {
"optional": true
}
},
"repository": {
"type": "git",
"url": "https://github.com/DataDog/browser-sdk.git",
"directory": "packages/rum-nextjs"
},
"volta": {
"extends": "../../package.json"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/react": "19.2.11",
"next": "15.3.3",
"react": "19.2.4"
}
}
7 changes: 7 additions & 0 deletions packages/rum-nextjs/pages-router/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@datadog/browser-rum-nextjs/pages-router",
"private": true,
"main": "../cjs/entries/pagesRouter.js",
"module": "../esm/entries/pagesRouter.js",
"types": "../cjs/entries/pagesRouter.d.ts"
}
58 changes: 58 additions & 0 deletions packages/rum-nextjs/src/domain/appRouter/datadogRumProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use client'

import React, { useRef, useEffect } from 'react'
import { usePathname, useParams } from 'next/navigation'
import { computeViewNameFromParams } from '../computeViewNameFromParams'
import { startNextjsView } from '../startNextjsView'

/**
* Tracks Next.js App Router views via `usePathname` and `useParams`.
*
* @example
* ```tsx
* // app/components/datadog-rum-provider.tsx
* 'use client'
*
* import { datadogRum } from '@datadog/browser-rum'
* import { nextjsPlugin } from '@datadog/browser-rum-nextjs'
* import { DatadogRumProvider } from '@datadog/browser-rum-nextjs/app-router'
*
* datadogRum.init({
* applicationId: '<APP_ID>',
* clientToken: '<CLIENT_TOKEN>',
* plugins: [nextjsPlugin({ router: 'app' })],
* })
*
* export default DatadogRumProvider
* ```
*
* ```tsx
* // app/layout.tsx
* import DatadogRumProvider from './components/datadog-rum-provider'
*
* export default function RootLayout({ children }: { children: React.ReactNode }) {
* return (
* <html>
* <body>
* <DatadogRumProvider>{children}</DatadogRumProvider>
* </body>
* </html>
* )
* }
* ```
*/
export function DatadogRumProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const params = useParams()
const previousPathnameRef = useRef<string | null>(null)

useEffect(() => {
if (previousPathnameRef.current !== pathname) {
previousPathnameRef.current = pathname
const viewName = computeViewNameFromParams(pathname, params as Record<string, string | string[] | undefined>)
startNextjsView(viewName)
}
}, [pathname, params])

return <>{children}</>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { computeViewNameFromParams } from './computeViewNameFromParams'

describe('computeViewNameFromParams', () => {
// prettier-ignore
const cases: Array<[string, string, Record<string, string | string[] | undefined>, string]> = [
// [description, pathname, params, expected]
// Static routes
['static path', '/about', {}, '/about'],
['nested static path', '/static/page', {}, '/static/page'],
// Single dynamic segment
['single dynamic segment', '/users/123', { id: '123' }, '/users/[id]'],
// Multiple dynamic segments
['multiple dynamic segments', '/users/123/posts/456', { userId: '123', postId: '456' }, '/users/[userId]/posts/[postId]'],
// Catch-all routes
['catch-all with multiple segments', '/docs/a/b/c', { slug: ['a', 'b', 'c'] }, '/docs/[...slug]'],
['catch-all with single segment', '/docs/intro', { slug: ['intro'] }, '/docs/[...slug]'],
// Ordering
['longer values replaced first', '/items/123/1', { id: '123', subId: '1' }, '/items/[id]/[subId]'],
// Edge cases
['undefined param values ignored', '/users/123', { id: '123', optional: undefined }, '/users/[id]'],
['empty string param values ignored', '/users/123', { id: '123', empty: '' }, '/users/[id]'],
['empty catch-all array ignored', '/docs', { slug: [] }, '/docs'],
]
cases.forEach(([description, pathname, params, expected]) => {
it(`${description}: "${pathname}" → "${expected}"`, () => {
expect(computeViewNameFromParams(pathname, params)).toBe(expected)
})
})
})
35 changes: 35 additions & 0 deletions packages/rum-nextjs/src/domain/computeViewNameFromParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export function computeViewNameFromParams(
pathname: string,
params: Record<string, string | string[] | undefined>
): string {
if (!params || Object.keys(params).length === 0) {
return pathname
}

let viewName = pathname

// Sort params by value length descending to replace longer values first.
// Prevents partial replacements (e.g., replacing '1' before '123').
const sortedParams = Object.entries(params).sort((a, b) => {
const aLen = Array.isArray(a[1]) ? a[1].join('/').length : (a[1]?.length ?? 0)
const bLen = Array.isArray(b[1]) ? b[1].join('/').length : (b[1]?.length ?? 0)
return bLen - aLen
})

for (const [paramName, paramValue] of sortedParams) {
if (paramValue === undefined) {
continue
}

if (Array.isArray(paramValue)) {
const joinedValue = paramValue.join('/')
if (joinedValue && viewName.includes(joinedValue)) {
viewName = viewName.replace(joinedValue, `[...${paramName}]`)
}
} else if (paramValue) {
viewName = viewName.replace(paramValue, `[${paramName}]`)
}
}

return viewName
}
134 changes: 134 additions & 0 deletions packages/rum-nextjs/src/domain/error/reportNextjsError.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type { RelativeTime, TimeStamp } from '@datadog/browser-core'
import { clocksNow, generateUUID, noop } from '@datadog/browser-core'
import type { RumInitConfiguration, RumPublicApi } from '@datadog/browser-rum-core'
import { RumEventType } from '@datadog/browser-rum-core'
import { registerCleanupTask, replaceMockable } from '../../../../core/test'
import { initializeNextjsPlugin } from '../../../test/initializeNextjsPlugin'
import { nextjsPlugin, resetNextjsPlugin } from '../nextjsPlugin'
import { reportNextjsError } from './reportNextjsError'

const FAKE_RELATIVE_TIME = 100 as RelativeTime
const FAKE_TIMESTAMP = 1000 as TimeStamp
const FAKE_UUID = 'fake-uuid-1234'

describe('reportNextjsError', () => {
beforeEach(() => {
replaceMockable(clocksNow, () => ({ relative: FAKE_RELATIVE_TIME, timeStamp: FAKE_TIMESTAMP }))
replaceMockable(generateUUID, () => FAKE_UUID)
})

it('reports an App Router error with digest', () => {
const addEventSpy = jasmine.createSpy()
initializeNextjsPlugin({ addEvent: addEventSpy })

const error = new Error('Test error')
;(error as any).digest = 'abc123'

reportNextjsError(error, noop)

expect(addEventSpy).toHaveBeenCalledOnceWith(
FAKE_RELATIVE_TIME,
jasmine.objectContaining({
type: RumEventType.ERROR,
date: FAKE_TIMESTAMP,
error: jasmine.objectContaining({
id: FAKE_UUID,
message: 'Test error',
source: 'source',
type: 'Error',
handling: 'unhandled',
source_type: 'browser',
}),
context: {
framework: 'nextjs',
router: 'app',
digest: 'abc123',
},
}),
{ error }
)
})

it('reports a Pages Router error with statusCode', () => {
const addEventSpy = jasmine.createSpy()
initializeNextjsPlugin({
configuration: { router: 'pages' },
addEvent: addEventSpy,
})

const error = new Error('Server error')

reportNextjsError(error, 500)

expect(addEventSpy).toHaveBeenCalledOnceWith(
FAKE_RELATIVE_TIME,
jasmine.objectContaining({
context: {
framework: 'nextjs',
router: 'pages',
statusCode: 500,
},
}),
{ error }
)
})

it('detects App Router when second argument is a function', () => {
const addEventSpy = jasmine.createSpy()
initializeNextjsPlugin({ addEvent: addEventSpy })

reportNextjsError(new Error('test'), noop)

const event = addEventSpy.calls.mostRecent().args[1]
expect(event.context.router).toBe('app')
})

it('detects Pages Router when second argument is a number', () => {
const addEventSpy = jasmine.createSpy()
initializeNextjsPlugin({ addEvent: addEventSpy })

reportNextjsError(new Error('test'), 404)

const event = addEventSpy.calls.mostRecent().args[1]
expect(event.context.router).toBe('pages')
})

it('defaults to Pages Router when no second argument', () => {
const addEventSpy = jasmine.createSpy()
initializeNextjsPlugin({ addEvent: addEventSpy })

reportNextjsError(new Error('test'))

const event = addEventSpy.calls.mostRecent().args[1]
expect(event.context.router).toBe('pages')
})

it('does not include digest when not present on error', () => {
const addEventSpy = jasmine.createSpy()
initializeNextjsPlugin({ addEvent: addEventSpy })

reportNextjsError(new Error('test'), noop)

const event = addEventSpy.calls.mostRecent().args[1]
expect(event.context.digest).toBeUndefined()
})

it('queues the error if RUM has not started yet', () => {
const addEventSpy = jasmine.createSpy()

reportNextjsError(new Error('queued error'), noop)

expect(addEventSpy).not.toHaveBeenCalled()

const plugin = nextjsPlugin({ router: 'app' })
plugin.onInit({
publicApi: {} as RumPublicApi,
initConfiguration: {} as RumInitConfiguration,
})
plugin.onRumStart({ addEvent: addEventSpy })

registerCleanupTask(() => resetNextjsPlugin())

expect(addEventSpy).toHaveBeenCalled()
})
})
Loading