diff --git a/packages/rum-react/src/domain/performance/addDurationVital.ts b/packages/rum-react/src/domain/performance/addDurationVital.ts new file mode 100644 index 0000000000..91ca505152 --- /dev/null +++ b/packages/rum-react/src/domain/performance/addDurationVital.ts @@ -0,0 +1,8 @@ +import type { RumPublicApi } from '@datadog/browser-rum-core' +import { onReactPluginInit } from '../reactPlugin' + +export const addDurationVital: RumPublicApi['addDurationVital'] = (name, options) => { + onReactPluginInit((_, rumPublicApi) => { + rumPublicApi.addDurationVital(name, options) + }) +} diff --git a/packages/rum-react/src/domain/performance/index.ts b/packages/rum-react/src/domain/performance/index.ts new file mode 100644 index 0000000000..06fdb9e674 --- /dev/null +++ b/packages/rum-react/src/domain/performance/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line camelcase +export { UNSTABLE_ReactComponentTracker } from './reactComponentTracker' diff --git a/packages/rum-react/src/domain/performance/reactComponentTracker.spec.tsx b/packages/rum-react/src/domain/performance/reactComponentTracker.spec.tsx new file mode 100644 index 0000000000..14f9df8245 --- /dev/null +++ b/packages/rum-react/src/domain/performance/reactComponentTracker.spec.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useLayoutEffect } from 'react' +import { flushSync } from 'react-dom' +import { appendComponent } from '../../../test/appendComponent' +import { initializeReactPlugin } from '../../../test/initializeReactPlugin' +import type { Clock } from '../../../../core/test' +import { mockClock, registerCleanupTask } from '../../../../core/test' +// eslint-disable-next-line camelcase +import { UNSTABLE_ReactComponentTracker } from './reactComponentTracker' + +const RENDER_DURATION = 100 +const EFFECT_DURATION = 101 +const LAYOUT_EFFECT_DURATION = 102 +const TOTAL_DURATION = RENDER_DURATION + EFFECT_DURATION + LAYOUT_EFFECT_DURATION + +function ChildComponent({ clock }: { clock: Clock }) { + clock.tick(RENDER_DURATION) + useEffect(() => clock.tick(EFFECT_DURATION)) + useLayoutEffect(() => clock.tick(LAYOUT_EFFECT_DURATION)) + return null +} + +describe('UNSTABLE_ReactComponentTracker', () => { + let clock: Clock + + beforeEach(() => { + clock = mockClock() + registerCleanupTask(() => clock.cleanup()) + }) + + it('should call addDurationVital after the component rendering', () => { + const addDurationVitalSpy = jasmine.createSpy() + initializeReactPlugin({ + publicApi: { + addDurationVital: addDurationVitalSpy, + }, + }) + + appendComponent( + // eslint-disable-next-line camelcase + + + + ) + + expect(addDurationVitalSpy).toHaveBeenCalledTimes(1) + const [name, options] = addDurationVitalSpy.calls.mostRecent().args + expect(name).toBe('reactComponentRender') + expect(options).toEqual({ + description: 'ChildComponent', + startTime: clock.timeStamp(0), + duration: TOTAL_DURATION, + context: { + is_first_render: true, + render_phase_duration: RENDER_DURATION, + effect_phase_duration: EFFECT_DURATION, + layout_effect_phase_duration: LAYOUT_EFFECT_DURATION, + framework: 'react', + }, + }) + }) + + it('should call addDurationVital on rerender', () => { + const addDurationVitalSpy = jasmine.createSpy() + initializeReactPlugin({ + publicApi: { + addDurationVital: addDurationVitalSpy, + }, + }) + + let forceUpdate: () => void + + function App() { + const [, setState] = React.useState(0) + forceUpdate = () => setState((prev) => prev + 1) + return ( + <> + {/* eslint-disable-next-line camelcase */} + + + + + ) + } + + appendComponent() + + clock.tick(1) + + flushSync(() => { + forceUpdate!() + }) + + expect(addDurationVitalSpy).toHaveBeenCalledTimes(2) + const options = addDurationVitalSpy.calls.mostRecent().args[1] + expect(options).toEqual({ + description: 'ChildComponent', + startTime: clock.timeStamp(TOTAL_DURATION + 1), + duration: TOTAL_DURATION, + context: { + is_first_render: false, + render_phase_duration: RENDER_DURATION, + effect_phase_duration: EFFECT_DURATION, + layout_effect_phase_duration: LAYOUT_EFFECT_DURATION, + framework: 'react', + }, + }) + }) +}) diff --git a/packages/rum-react/src/domain/performance/reactComponentTracker.tsx b/packages/rum-react/src/domain/performance/reactComponentTracker.tsx new file mode 100644 index 0000000000..a070e01ff9 --- /dev/null +++ b/packages/rum-react/src/domain/performance/reactComponentTracker.tsx @@ -0,0 +1,78 @@ +import * as React from 'react' +import { createTimer } from './timer' +import { addDurationVital } from './addDurationVital' + +// eslint-disable-next-line +export const UNSTABLE_ReactComponentTracker = ({ + name: componentName, + children, +}: { + name: string + children?: React.ReactNode +}) => { + const isFirstRender = React.useRef(true) + + const renderTimer = createTimer() + const effectTimer = createTimer() + const layoutEffectTimer = createTimer() + + const onEffectEnd = () => { + const renderDuration = renderTimer.getDuration() ?? 0 + const effectDuration = effectTimer.getDuration() ?? 0 + const layoutEffectDuration = layoutEffectTimer.getDuration() ?? 0 + + const totalRenderTime = renderDuration + effectDuration + layoutEffectDuration + + addDurationVital('reactComponentRender', { + description: componentName, + startTime: renderTimer.getStartTime()!, // note: renderTimer should have been started at this point, so getStartTime should not return undefined + duration: totalRenderTime, + context: { + is_first_render: isFirstRender.current, + render_phase_duration: renderDuration, + effect_phase_duration: effectDuration, + layout_effect_phase_duration: layoutEffectDuration, + framework: 'react', + }, + }) + + isFirstRender.current = false + } + + // In react, children are rendered sequentially in the order they are defined. that's why we can + // measure perf timings of a component by starting recordings in the component above and stopping + // them in the component below. + return ( + <> + + {children} + { + effectTimer.stopTimer() + onEffectEnd() + }} + /> + + ) +} + +function LifeCycle({ + onRender, + onLayoutEffect, + onEffect, +}: { + onRender: () => void + onLayoutEffect: () => void + onEffect: () => void +}) { + onRender() + React.useLayoutEffect(onLayoutEffect) + React.useEffect(onEffect) + return null +} diff --git a/packages/rum-react/src/domain/performance/timer.spec.ts b/packages/rum-react/src/domain/performance/timer.spec.ts new file mode 100644 index 0000000000..fd60e7f106 --- /dev/null +++ b/packages/rum-react/src/domain/performance/timer.spec.ts @@ -0,0 +1,16 @@ +import { mockClock, registerCleanupTask } from '@datadog/browser-core/test' +import type { Duration } from '@datadog/browser-core' +import { createTimer } from './timer' + +describe('createTimer', () => { + it('is able to measure time', () => { + const clock = mockClock() + registerCleanupTask(clock.cleanup) + + const timer = createTimer() + timer.startTimer() + clock.tick(1000) + timer.stopTimer() + expect(timer.getDuration()).toBe(1000 as Duration) + }) +}) diff --git a/packages/rum-react/src/domain/performance/timer.ts b/packages/rum-react/src/domain/performance/timer.ts new file mode 100644 index 0000000000..32fecfb0eb --- /dev/null +++ b/packages/rum-react/src/domain/performance/timer.ts @@ -0,0 +1,32 @@ +import type { Duration, RelativeTime, TimeStamp } from '@datadog/browser-core' +import { elapsed, relativeNow, timeStampNow } from '@datadog/browser-core' + +export function createTimer() { + let duration: Duration | undefined + let startTime: TimeStamp | undefined + let highPrecisionStartTime: RelativeTime | undefined + + return { + startTimer(this: void) { + // timeStampNow uses Date.now() internally, which is not high precision, but this is what is + // used for other events, so we use it here as well. + startTime = timeStampNow() + + // relativeNow uses performance.now() which is higher precision than Date.now(), so we use for + // the duration + highPrecisionStartTime = relativeNow() + }, + + stopTimer(this: void) { + duration = elapsed(highPrecisionStartTime!, relativeNow()) + }, + + getDuration(this: void) { + return duration + }, + + getStartTime(this: void) { + return startTime + }, + } +} diff --git a/packages/rum-react/src/entries/main.ts b/packages/rum-react/src/entries/main.ts index dd221ccccd..b188d536c4 100644 --- a/packages/rum-react/src/entries/main.ts +++ b/packages/rum-react/src/entries/main.ts @@ -1,2 +1,4 @@ export { ErrorBoundary, addReactError } from '../domain/error' export { reactPlugin } from '../domain/reactPlugin' +// eslint-disable-next-line camelcase +export { UNSTABLE_ReactComponentTracker } from '../domain/performance' diff --git a/sandbox/react-app/main.tsx b/sandbox/react-app/main.tsx index 8824d49ff4..07188e9b33 100644 --- a/sandbox/react-app/main.tsx +++ b/sandbox/react-app/main.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react' import ReactDOM from 'react-dom/client' import { datadogRum } from '@datadog/browser-rum' import { createBrowserRouter } from '@datadog/browser-rum-react/react-router-v6' -import { reactPlugin, ErrorBoundary } from '@datadog/browser-rum-react' +import { reactPlugin, ErrorBoundary, UNSTABLE_ReactComponentTracker } from '@datadog/browser-rum-react' datadogRum.init({ applicationId: 'xxx', @@ -66,7 +66,11 @@ function HomePage() { function UserPage() { const { id } = useParams() - return

User {id}

+ return ( + +

User {id}

+
+ ) } function WildCardPage() { diff --git a/webpack.base.js b/webpack.base.js index be8744cf74..ae6461f206 100644 --- a/webpack.base.js +++ b/webpack.base.js @@ -36,7 +36,7 @@ module.exports = ({ entry, mode, filename, types, keepBuildEnvVariables, plugins }, resolve: { - extensions: ['.ts', '.js'], + extensions: ['.ts', '.js', '.tsx'], plugins: [new TsconfigPathsPlugin({ configFile: tsconfigPath })], alias: { // The default "pako.esm.js" build is not transpiled to es5