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 .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ coverage
rum-events-format
.yarn
test/**/dist
test/**/.next
yarn.lock
/docs
/developer-extension/.output
Expand Down
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
3 changes: 3 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export default tseslint.config(
'packages/*/cjs',
'packages/*/esm',
'test/**/dist',
'test/**/.next',
'test/apps/nextjs-app-router',
'test/apps/nextjs-pages-router',
'test/apps/react-heavy-spa',
'test/apps/react-shopist-like',
'sandbox',
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"
}
52 changes: 52 additions & 0 deletions packages/rum-nextjs/src/domain/addDurationVital.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { RumPublicApi, RumInitConfiguration } from '@datadog/browser-rum-core'
import { registerCleanupTask } from '../../../core/test'
import { nextjsPlugin, resetNextjsPlugin } from './nextjsPlugin'
import { addDurationVital } from './addDurationVital'

const routerTypes = ['app', 'pages'] as const

routerTypes.forEach((routerType) => {
describe(`addDurationVital (router: '${routerType}')`, () => {
beforeEach(() => {
registerCleanupTask(() => {
resetNextjsPlugin()
})
})

it('should forward to rumPublicApi.addDurationVital after nextjsPlugin init', () => {
const addDurationVitalSpy = jasmine.createSpy()
const publicApi = { addDurationVital: addDurationVitalSpy } as unknown as RumPublicApi

addDurationVital('reactComponentRender', {
description: 'MyComponent',
startTime: 0 as any,
duration: 100,
})

expect(addDurationVitalSpy).not.toHaveBeenCalled()

const plugin = nextjsPlugin({ router: routerType })
plugin.onInit({ publicApi, initConfiguration: {} as RumInitConfiguration })

expect(addDurationVitalSpy).toHaveBeenCalledTimes(1)
expect(addDurationVitalSpy.calls.mostRecent().args[0]).toBe('reactComponentRender')
expect(addDurationVitalSpy.calls.mostRecent().args[1].description).toBe('MyComponent')
})

it('should call immediately if nextjsPlugin is already initialized', () => {
const addDurationVitalSpy = jasmine.createSpy()
const publicApi = { addDurationVital: addDurationVitalSpy } as unknown as RumPublicApi

const plugin = nextjsPlugin({ router: routerType })
plugin.onInit({ publicApi, initConfiguration: {} as RumInitConfiguration })

addDurationVital('reactComponentRender', {
description: 'MyComponent',
startTime: 0 as any,
duration: 50,
})

expect(addDurationVitalSpy).toHaveBeenCalledTimes(1)
})
})
})
8 changes: 8 additions & 0 deletions packages/rum-nextjs/src/domain/addDurationVital.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { RumPublicApi } from '@datadog/browser-rum-core'
import { onRumInit } from './nextjsPlugin'

export const addDurationVital: RumPublicApi['addDurationVital'] = (name, options) => {
onRumInit((_, rumPublicApi) => {
rumPublicApi.addDurationVital(name, options)
})
}
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
}
Loading