Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[React RUM] Add a ReactComponentTracker component #3086

Merged
merged 23 commits into from
Jan 23, 2025
Merged
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
Original file line number Diff line number Diff line change
@@ -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)
})
}
2 changes: 2 additions & 0 deletions packages/rum-react/src/domain/performance/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line camelcase
export { UNSTABLE_ReactComponentTracker } from './reactComponentTracker'
Original file line number Diff line number Diff line change
@@ -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
<UNSTABLE_ReactComponentTracker name="ChildComponent">
<ChildComponent clock={clock} />
</UNSTABLE_ReactComponentTracker>
)

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 */}
<UNSTABLE_ReactComponentTracker name="ChildComponent">
<ChildComponent clock={clock} />
</UNSTABLE_ReactComponentTracker>
</>
)
}

appendComponent(<App />)

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',
},
})
})
})
RomanGaignault marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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 (
<>
<LifeCycle
onRender={renderTimer.startTimer}
onLayoutEffect={layoutEffectTimer.startTimer}
onEffect={effectTimer.startTimer}
/>
{children}
<LifeCycle
onRender={renderTimer.stopTimer}
onLayoutEffect={layoutEffectTimer.stopTimer}
onEffect={() => {
effectTimer.stopTimer()
onEffectEnd()
}}
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems like a good approach! It's a bit unfortunate that no timing information for the reconciliation phase is captured (unless I misunderstand something), but that's admittedly a bit harder to capture, since there are no natural callbacks to hook into. At some point in the future, it might be nice to see if we can hook into the profiler hooks that ReactFiberWorkLoop exposes internally...

Copy link
Member

Choose a reason for hiding this comment

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

Or we could directly use the Profiler :) We'll see.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed @sethfowler-datadog that it would be a lot better if we could use the hooks that the dev tools use as they track everything we need.
I'm not against testing the Profiler but I still think that it will be limiting as the docs about how to use it are very poorly written and I think it will bring us support issues about people not being able to make it work properly with their build system.

/>
</>
)
}

function LifeCycle({
onRender,
onLayoutEffect,
onEffect,
}: {
onRender: () => void
onLayoutEffect: () => void
onEffect: () => void
}) {
onRender()
React.useLayoutEffect(onLayoutEffect)
React.useEffect(onEffect)
return null
}
16 changes: 16 additions & 0 deletions packages/rum-react/src/domain/performance/timer.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
32 changes: 32 additions & 0 deletions packages/rum-react/src/domain/performance/timer.ts
Original file line number Diff line number Diff line change
@@ -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
},
}
}
2 changes: 2 additions & 0 deletions packages/rum-react/src/entries/main.ts
Original file line number Diff line number Diff line change
@@ -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'
8 changes: 6 additions & 2 deletions sandbox/react-app/main.tsx
Original file line number Diff line number Diff line change
@@ -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 <h1>User {id}</h1>
return (
<UNSTABLE_ReactComponentTracker name="UserPage">
<h1>User {id}</h1>
</UNSTABLE_ReactComponentTracker>
)
}

function WildCardPage() {
2 changes: 1 addition & 1 deletion webpack.base.js
Original file line number Diff line number Diff line change
@@ -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