From 45eaa302764ef3facf029629eb0d911d94431ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9o=20Chaillou?= Date: Mon, 20 Apr 2026 10:28:41 +0200 Subject: [PATCH 1/2] feat(profiling): add react profiler --- .../core/src/domain/deflate/deflate.types.ts | 1 + .../domain/telemetry/telemetryEvent.types.ts | 6 +- packages/rum-core/src/boot/rumPublicApi.ts | 11 +- packages/rum-core/src/domain/plugins.ts | 34 +- .../viewMetrics/trackCommonViewMetrics.ts | 4 +- packages/rum-core/src/rumEvent.types.ts | 2 + .../src/domain/createReactProfiler.ts | 282 ++++++++++++ .../collectReactComponentRender.ts | 34 ++ .../rum-react/src/domain/performance/index.ts | 2 + .../domain/performance/reactProfiler.spec.tsx | 433 ++++++++++++++++++ .../src/domain/performance/reactProfiler.tsx | 164 +++++++ packages/rum-react/src/domain/reactPlugin.ts | 27 +- packages/rum-react/src/entries/main.ts | 3 +- .../rum-react/src/types/reactProfileTrace.ts | 54 +++ .../rum-react/src/types/reactProfiling.ts | 67 +++ .../rum-react/test/initializeReactPlugin.ts | 33 +- rum-events-format | 2 +- scripts/lib/generatedSchemaTypes.ts | 8 + 18 files changed, 1158 insertions(+), 9 deletions(-) create mode 100644 packages/rum-react/src/domain/createReactProfiler.ts create mode 100644 packages/rum-react/src/domain/performance/collectReactComponentRender.ts create mode 100644 packages/rum-react/src/domain/performance/reactProfiler.spec.tsx create mode 100644 packages/rum-react/src/domain/performance/reactProfiler.tsx create mode 100644 packages/rum-react/src/types/reactProfileTrace.ts create mode 100644 packages/rum-react/src/types/reactProfiling.ts diff --git a/packages/core/src/domain/deflate/deflate.types.ts b/packages/core/src/domain/deflate/deflate.types.ts index a8b1a485ed..a6a88a0f90 100644 --- a/packages/core/src/domain/deflate/deflate.types.ts +++ b/packages/core/src/domain/deflate/deflate.types.ts @@ -54,4 +54,5 @@ export const enum DeflateEncoderStreamId { RUM = 2, TELEMETRY = 4, PROFILING = 6, + REACT_PROFILING = 7, } diff --git a/packages/core/src/domain/telemetry/telemetryEvent.types.ts b/packages/core/src/domain/telemetry/telemetryEvent.types.ts index 81033d1c71..a990a3bb7c 100644 --- a/packages/core/src/domain/telemetry/telemetryEvent.types.ts +++ b/packages/core/src/domain/telemetry/telemetryEvent.types.ts @@ -470,6 +470,10 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & { * Whether trace baggage is propagated to child spans */ propagate_trace_baggage?: boolean + /** + * How the SDK tracks resource request/response headers + */ + use_track_resource_headers?: 'default_headers' | 'custom' /** * Whether the beta encode cookie options is enabled */ @@ -981,6 +985,6 @@ export interface AndroidNetworkInstrumentation { /** * The network instrumentation API used */ - type: 'CRONET' | 'OKHTTP' + type: 'CRONET' | 'OKHTTP' | 'LEGACY_OKHTTP' [k: string]: unknown } diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index a3fd82649a..3d281caa07 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -646,6 +646,12 @@ export function makeRumPublicApi( strategy, // TODO: remove this in the next major release addEvent: startRumResult.addEvent, addError: startRumResult.addError, + profilingEndpointBuilder: configuration.profilingEndpointBuilder, + configuration, + lifeCycle: startRumResult.lifeCycle, + session: startRumResult.session, + viewHistory: startRumResult.viewHistory, + createEncoder, }) return startRumResult @@ -788,9 +794,12 @@ export function makeRumPublicApi( setViewLoadingTime: monitor(() => { const callTimestamp = timeStampNow() - strategy.setLoadingTime(callTimestamp) + const result = strategy.setLoadingTime(callTimestamp) addTelemetryUsage({ feature: 'addViewLoadingTime', + no_view: result?.noView ?? false, + no_active_view: result?.noActiveView ?? false, + overwritten: result?.overwritten ?? false, }) }), diff --git a/packages/rum-core/src/domain/plugins.ts b/packages/rum-core/src/domain/plugins.ts index 0cd830bf21..315ef8646d 100644 --- a/packages/rum-core/src/domain/plugins.ts +++ b/packages/rum-core/src/domain/plugins.ts @@ -1,6 +1,10 @@ +import type { DeflateEncoderStreamId, Encoder, EndpointBuilder } from '@datadog/browser-core' import type { RumPublicApi, Strategy } from '../boot/rumPublicApi' import type { StartRumResult } from '../boot/startRum' -import type { RumInitConfiguration } from './configuration' +import type { RumConfiguration, RumInitConfiguration } from './configuration' +import type { LifeCycle } from './lifeCycle' +import type { RumSessionManager } from './rumSessionManager' +import type { ViewHistory } from './contexts/viewHistory' /** * onRumStart plugin API options. @@ -20,6 +24,34 @@ export interface OnRumStartOptions { * Add a custom error to the RUM browser SDK. */ addError?: StartRumResult['addError'] + /** + * Endpoint builder for the profiling intake. Can be used by plugins to send profiling events + * to the same endpoint as the browser CPU profiler. + */ + profilingEndpointBuilder?: EndpointBuilder + /** + * RUM configuration. Can be used by plugins to access SDK settings such as applicationId, + * profilingSampleRate, and other profiling endpoint options. + */ + configuration?: RumConfiguration + /** + * LifeCycle instance. Can be used by plugins to subscribe to RUM lifecycle events such as + * SESSION_EXPIRED, SESSION_RENEWED, and VIEW_CREATED. + */ + lifeCycle?: LifeCycle + /** + * Session manager. Can be used by plugins to find the currently tracked session. + */ + session?: RumSessionManager + /** + * View history. Can be used by plugins to find the current view context. + */ + viewHistory?: ViewHistory + /** + * Factory function to create a deflate encoder for a given stream. Can be used by plugins + * to compress profile attachments using the same encoder as the browser CPU profiler. + */ + createEncoder?: (streamId: DeflateEncoderStreamId) => Encoder } /** diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackCommonViewMetrics.ts b/packages/rum-core/src/domain/view/viewMetrics/trackCommonViewMetrics.ts index 5e1d96c845..190196f4b7 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/trackCommonViewMetrics.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/trackCommonViewMetrics.ts @@ -84,8 +84,9 @@ export function trackCommonViewMetrics( }, setLoadingTime: (callTimestamp?: TimeStamp) => { if (viewEnded) { - return + return { noView: false, noActiveView: true, overwritten: false } } + const overwritten = hasManualLoadingTime const loadingTime = elapsed(viewStart.timeStamp, callTimestamp ?? timeStampNow()) if (!hasManualLoadingTime) { stopLoadingTimeTracking() @@ -93,6 +94,7 @@ export function trackCommonViewMetrics( hasManualLoadingTime = true commonViewMetrics.loadingTime = loadingTime scheduleViewUpdate() + return { noView: false, noActiveView: false, overwritten } }, } } diff --git a/packages/rum-core/src/rumEvent.types.ts b/packages/rum-core/src/rumEvent.types.ts index 04e06ab94b..fef1b42d04 100644 --- a/packages/rum-core/src/rumEvent.types.ts +++ b/packages/rum-core/src/rumEvent.types.ts @@ -1732,6 +1732,8 @@ export interface ViewProperties { | 'fragment_redisplay' | 'view_controller_display' | 'view_controller_redisplay' + | 'session_renewal' + | 'bf_cache' /** * Time spent on the view in ns */ diff --git a/packages/rum-react/src/domain/createReactProfiler.ts b/packages/rum-react/src/domain/createReactProfiler.ts new file mode 100644 index 0000000000..9845e5d125 --- /dev/null +++ b/packages/rum-react/src/domain/createReactProfiler.ts @@ -0,0 +1,282 @@ +import type { Duration, Encoder } from '@datadog/browser-core' +import { + addEventListener, + clearTimeout, + setTimeout, + DOM_EVENT, + DeflateEncoderStreamId, + toServerDuration, + clocksNow, +} from '@datadog/browser-core' +import type { LifeCycle, RumConfiguration, RumSessionManager, TransportPayload, ViewHistory } from '@datadog/browser-rum-core' +import { createFormDataTransport, LifeCycleEventType } from '@datadog/browser-rum-core' +import type { ReactProfileEvent } from '../types/reactProfiling' +import type { ReactProfileTrace } from '../types/reactProfileTrace' + +export interface ComponentRenderData { + component: string + startTime: number + duration: Duration + phase: 'mount' | 'update' | 'nested-update' + renderPhaseDuration?: Duration + layoutEffectPhaseDuration?: Duration + effectPhaseDuration?: Duration + baseDurationMs?: number +} + +export interface ReactProfilingController { + start(): void + stop(): void + isRunning(): boolean + isStopped(): boolean + addComponentRender(data: ComponentRenderData): void +} + +export interface ReactProfilerConfiguration { + collectIntervalMs: number +} + +export const DEFAULT_REACT_PROFILER_CONFIGURATION: ReactProfilerConfiguration = { + collectIntervalMs: 60_000, +} + +interface ReactProfilerRunningInstance { + state: 'running' + startTimeStamp: number + timeoutId: ReturnType + views: Array<{ id: string; name: string }> + pendingBatchRenders: ComponentRenderData[] + batchFlushScheduled: boolean + samples: ReactProfileTrace['samples'] + cleanupTasks: Array<() => void> +} + +interface ReactProfilerStoppedInstance { + state: 'stopped' + stateReason: 'initializing' | 'session-expired' | 'stopped-by-user' +} + +interface ReactProfilerPausedInstance { + state: 'paused' +} + +type ReactProfilerInstance = ReactProfilerRunningInstance | ReactProfilerStoppedInstance | ReactProfilerPausedInstance + +export function createReactProfiler( + configuration: RumConfiguration, + lifeCycle: LifeCycle, + sessionManager: RumSessionManager, + createEncoder: (streamId: DeflateEncoderStreamId) => Encoder, + viewHistory: ViewHistory, + profilerConfiguration: ReactProfilerConfiguration = DEFAULT_REACT_PROFILER_CONFIGURATION +): ReactProfilingController { + const transport = createFormDataTransport( + configuration, + lifeCycle, + createEncoder, + DeflateEncoderStreamId.REACT_PROFILING + ) + + let instance: ReactProfilerInstance = { state: 'stopped', stateReason: 'initializing' } + let lastView: { id: string; name: string } | undefined + + // Store clean-up tasks for this instance (tasks to be executed when the Profiler is stopped or paused.) + const globalCleanupTasks: Array<() => void> = [] + + // Stops the profiler when session expires + lifeCycle.subscribe(LifeCycleEventType.SESSION_EXPIRED, () => { + stopProfiling('session-expired') + }) + + // Start the profiler again when session is renewed + lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { + if (instance.state === 'stopped' && instance.stateReason === 'session-expired') { + start() + } + }) + + function start(): void { + if (instance.state === 'running') { + return + } + + const viewEntry = viewHistory.findView() + lastView = viewEntry ? { id: viewEntry.id, name: viewEntry.name ?? '' } : undefined + + globalCleanupTasks.push( + addEventListener(configuration, window, DOM_EVENT.VISIBILITY_CHANGE, handleVisibilityChange).stop, + addEventListener(configuration, window, DOM_EVENT.BEFORE_UNLOAD, handleBeforeUnload).stop + ) + + startNextWindow() + } + + function addEventListeners(existingInstance: ReactProfilerInstance) { + if (existingInstance.state === 'running') { + return { cleanupTasks: existingInstance.cleanupTasks } + } + + const cleanupTasks = [] + + const subscription = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => { + const viewEntry = { id: view.id, name: view.name ?? '' } + if (instance.state === 'running') { + instance.views.push(viewEntry) + } + lastView = viewEntry + }) + cleanupTasks.push(subscription.unsubscribe) + + return { cleanupTasks } + } + + function startNextWindow(): void { + if (instance.state === 'running') { + collectAndSendWindow(instance) + } + + const { cleanupTasks } = addEventListeners(instance) + + instance = { + state: 'running', + startTimeStamp: clocksNow().timeStamp, + timeoutId: setTimeout(startNextWindow, profilerConfiguration.collectIntervalMs), + views: lastView ? [lastView] : [], + pendingBatchRenders: [], + batchFlushScheduled: false, + samples: [], + cleanupTasks, + } + } + + function collectAndSendWindow(runningInstance: ReactProfilerRunningInstance): void { + clearTimeout(runningInstance.timeoutId) + + // Synchronously flush any pending batch renders before collecting + if (runningInstance.pendingBatchRenders.length > 0) { + flushBatch(runningInstance) + } + + const { samples, views, startTimeStamp } = runningInstance + if (samples.length === 0) { + return + } + + const sessionId = sessionManager.findTrackedSession()?.id + + const event: ReactProfileEvent = { + application: { id: configuration.applicationId }, + ...(sessionId && { session: { id: sessionId } }), + ...(views.length > 0 && { + view: { + id: views.map((v) => v.id), + name: views.map((v) => v.name), + }, + }), + start: new Date(startTimeStamp).toISOString(), + end: new Date().toISOString(), + attachments: ['react-profiling.json'], + } + + void transport.send({ event, 'react-profiling.json': { samples } } as unknown as TransportPayload) + } + + function stopProfiling(reason: ReactProfilerStoppedInstance['stateReason']): void { + if (instance.state === 'running') { + const runningInstance = instance + instance = { state: 'stopped', stateReason: reason } + runningInstance.cleanupTasks.forEach((task) => task()) + collectAndSendWindow(runningInstance) + } else if (instance.state === 'paused') { + instance = { state: 'stopped', stateReason: reason } + } + + globalCleanupTasks.forEach((task) => task()) + globalCleanupTasks.length = 0 + } + + function pauseProfilerInstance(): void { + if (instance.state !== 'running') { + return + } + + const runningInstance = instance + instance = { state: 'paused' } + runningInstance.cleanupTasks.forEach((task) => task()) + collectAndSendWindow(runningInstance) + } + + function handleVisibilityChange(): void { + if (document.visibilityState === 'hidden' && instance.state === 'running') { + pauseProfilerInstance() + } else if (document.visibilityState === 'visible' && instance.state === 'paused') { + start() + } + } + + function handleBeforeUnload(): void { + startNextWindow() + } + + function addComponentRender(data: ComponentRenderData): void { + if (instance.state !== 'running') { + return + } + + instance.pendingBatchRenders.push(data) + + if (!instance.batchFlushScheduled) { + instance.batchFlushScheduled = true + // React runs all useEffect callbacks from a single commit synchronously in the same + // MessageChannel task, so a microtask scheduled from the first effect fires after all + // of them — letting us group them into one sample. + queueMicrotask(() => { + if (instance.state === 'running') { + flushBatch(instance) + } + }) + } + } + + function flushBatch(runningInstance: ReactProfilerRunningInstance): void { + runningInstance.batchFlushScheduled = false + + if (runningInstance.pendingBatchRenders.length === 0) { + return + } + + const renders = runningInstance.pendingBatchRenders + runningInstance.pendingBatchRenders = [] + + const sampleTimestampMs = Math.min(...renders.map((r) => r.startTime)) + + runningInstance.samples.push({ + timestamp: new Date(sampleTimestampMs).toISOString(), + renders: renders.map((r) => ({ + component: r.component, + phase: r.phase, + duration: toServerDuration(r.duration), + ...(r.renderPhaseDuration !== undefined && { + render_phase_duration: toServerDuration(r.renderPhaseDuration), + }), + ...(r.layoutEffectPhaseDuration !== undefined && { + layout_effect_phase_duration: toServerDuration(r.layoutEffectPhaseDuration), + }), + ...(r.effectPhaseDuration !== undefined && { + effect_phase_duration: toServerDuration(r.effectPhaseDuration), + }), + ...(r.baseDurationMs !== undefined && { + base_duration: toServerDuration(r.baseDurationMs as Duration), + }), + })), + }) + } + + return { + start, + stop: () => stopProfiling('stopped-by-user'), + addComponentRender, + isRunning: () => instance.state === 'running', + isStopped: () => instance.state === 'stopped', + } +} diff --git a/packages/rum-react/src/domain/performance/collectReactComponentRender.ts b/packages/rum-react/src/domain/performance/collectReactComponentRender.ts new file mode 100644 index 0000000000..188835045b --- /dev/null +++ b/packages/rum-react/src/domain/performance/collectReactComponentRender.ts @@ -0,0 +1,34 @@ +import type { Duration, TimeStamp } from '@datadog/browser-core' +import { getRunningReactProfiler } from '../reactPlugin' + +export function collectReactComponentRender({ + component, + startTime, + duration, + phase, + renderPhaseDuration, + layoutEffectPhaseDuration, + effectPhaseDuration, + baseDurationMs, +}: { + component: string + startTime: TimeStamp + duration: Duration + phase: 'mount' | 'update' | 'nested-update' + renderPhaseDuration?: Duration + layoutEffectPhaseDuration?: Duration + effectPhaseDuration?: Duration + /** Base duration in milliseconds as reported by React.Profiler (already a float ms value). */ + baseDurationMs?: number +}) { + getRunningReactProfiler()?.addComponentRender({ + component, + startTime: startTime as unknown as number, + duration, + phase, + renderPhaseDuration, + layoutEffectPhaseDuration, + effectPhaseDuration, + baseDurationMs, + }) +} diff --git a/packages/rum-react/src/domain/performance/index.ts b/packages/rum-react/src/domain/performance/index.ts index 06fdb9e674..693140b974 100644 --- a/packages/rum-react/src/domain/performance/index.ts +++ b/packages/rum-react/src/domain/performance/index.ts @@ -1,2 +1,4 @@ // eslint-disable-next-line camelcase export { UNSTABLE_ReactComponentTracker } from './reactComponentTracker' +export { ReactProfiler } from './reactProfiler' +export type { ReactProfilerContext } from './reactProfiler' diff --git a/packages/rum-react/src/domain/performance/reactProfiler.spec.tsx b/packages/rum-react/src/domain/performance/reactProfiler.spec.tsx new file mode 100644 index 0000000000..6c3d07838a --- /dev/null +++ b/packages/rum-react/src/domain/performance/reactProfiler.spec.tsx @@ -0,0 +1,433 @@ +import React, { act } from 'react' +import { createIdentityEncoder } from '@datadog/browser-core' +import { + interceptRequests, + DEFAULT_FETCH_MOCK, + readFormDataRequest, + waitNextMicrotask, +} from '@datadog/browser-core/test' +import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core' +import { createRumSessionManagerMock, mockRumConfiguration, mockViewHistory } from '@datadog/browser-rum-core/test' +import { registerCleanupTask } from '../../../../core/test' +import { appendComponent } from '../../../test/appendComponent' +import { initializeReactPlugin } from '../../../test/initializeReactPlugin' +import { initReactOldBrowsersSupport } from '../../../test/reactOldBrowsersSupport' +import type { Clock } from '../../../../core/test' +import { mockClock } from '../../../../core/test' +import type { ReactProfileEvent } from '../../types/reactProfiling' +import type { ReactProfileTrace } from '../../types/reactProfileTrace' +import { ReactProfiler } from './reactProfiler' + +interface ReactProfilePayload { + event: ReactProfileEvent + 'react-profiling.json': ReactProfileTrace +} + +const RENDER_DURATION = 100 +const EFFECT_DURATION = 101 +const LAYOUT_EFFECT_DURATION = 102 +const TOTAL_DURATION_NS = (RENDER_DURATION + EFFECT_DURATION + LAYOUT_EFFECT_DURATION) * 1e6 + +function ChildComponent({ clock }: { clock: Clock }) { + clock.tick(RENDER_DURATION) + React.useEffect(() => clock.tick(EFFECT_DURATION)) + React.useLayoutEffect(() => clock.tick(LAYOUT_EFFECT_DURATION)) + return null +} + +/** + * Replace React.Profiler with a passthrough so the sentinel path runs without + * the Profiler firing. Registers a cleanup task to restore the original. + */ +function disableReactProfiler() { + const originalProfiler = React.Profiler + ;(React as any).Profiler = ({ children }: { children?: React.ReactNode }) => + React.createElement(React.Fragment, null, children) + registerCleanupTask(() => { + ;(React as any).Profiler = originalProfiler + }) +} + +describe('ReactProfiler', () => { + let clock: Clock + let interceptor: ReturnType + + beforeEach(() => { + clock = mockClock() + initReactOldBrowsersSupport() + interceptor = interceptRequests() + interceptor.withFetch(DEFAULT_FETCH_MOCK, DEFAULT_FETCH_MOCK, DEFAULT_FETCH_MOCK) + }) + + function setupProfiler({ profilingSampleRate = 100 }: { profilingSampleRate?: number } = {}) { + const lifeCycle = new LifeCycle() + const sessionManager = createRumSessionManagerMock().setId('session-id-1') + const viewHistory = mockViewHistory({ id: 'view-id-1', name: 'view-name-1' }) + const rumConfiguration = mockRumConfiguration({ profilingSampleRate }) + + initializeReactPlugin({ + lifeCycle, + sessionManager, + viewHistory, + createEncoder: createIdentityEncoder, + rumConfiguration, + }) + + return { lifeCycle, sessionManager } + } + + async function getProfilePayload(): Promise { + // Flush: queueMicrotask (batch flush) + transport.send async chain + await waitNextMicrotask() + await waitNextMicrotask() + return readFormDataRequest(interceptor.requests[interceptor.requests.length - 1]) + } + + function getSamples(payload: ReactProfilePayload) { + return payload['react-profiling.json'].samples + } + + describe('sampling', () => { + it('should not collect data when profilingSampleRate is 0', async () => { + setupProfiler({ profilingSampleRate: 0 }) + + appendComponent( + + + + ) + + await waitNextMicrotask() + await waitNextMicrotask() + expect(interceptor.requests.length).toBe(0) + }) + + it('should just render children when not sampled', () => { + setupProfiler({ profilingSampleRate: 0 }) + + const container = appendComponent( + +
+ + ) + + expect(container.querySelector('#child')).toBeTruthy() + }) + }) + + describe('profiling build mode (React.Profiler fires)', () => { + it('should emit a profile with a mount phase render', async () => { + const { lifeCycle } = setupProfiler() + + appendComponent( + + + + ) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + const samples = getSamples(payload) + expect(samples.length).toBe(1) + expect(samples[0].renders[0].component).toBe('ChildComponent') + expect(samples[0].renders[0].phase).toBe('mount') + }) + + it('should include base_duration', async () => { + const { lifeCycle } = setupProfiler() + + appendComponent( + + + + ) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + const render = getSamples(payload)[0].renders[0] + expect(render.base_duration).toBeDefined() + expect(typeof render.base_duration).toBe('number') + }) + + it('should not include phase split fields', async () => { + const { lifeCycle } = setupProfiler() + + appendComponent( + + + + ) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + const render = getSamples(payload)[0].renders[0] + expect(render.render_phase_duration).toBeUndefined() + expect(render.layout_effect_phase_duration).toBeUndefined() + expect(render.effect_phase_duration).toBeUndefined() + }) + + it('should report update phase on re-render', async () => { + const { lifeCycle } = setupProfiler() + + let forceUpdate: () => void + + function App() { + const [, setState] = React.useState(0) + forceUpdate = () => setState((prev) => prev + 1) + return ( + + + + ) + } + + appendComponent() + + act(() => { + forceUpdate!() + }) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + const samples = getSamples(payload) + expect(samples.length).toBe(2) + expect(samples[1].renders[0].phase).toBe('update') + }) + + it('should still render children', () => { + setupProfiler() + + const container = appendComponent( + +
+ + ) + + expect(container.querySelector('#child')).toBeTruthy() + }) + }) + + describe('standard mode (React.Profiler disabled / no profiling build)', () => { + beforeEach(() => { + disableReactProfiler() + }) + + it('should emit a profile with a mount phase render and correct duration', async () => { + const { lifeCycle } = setupProfiler() + + appendComponent( + + + + ) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + const render = getSamples(payload)[0].renders[0] + expect(render.component).toBe('ChildComponent') + expect(render.phase).toBe('mount') + expect(render.duration).toBe(TOTAL_DURATION_NS) + }) + + it('should include phase split durations in nanoseconds', async () => { + const { lifeCycle } = setupProfiler() + + appendComponent( + + + + ) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + const render = getSamples(payload)[0].renders[0] + expect(render.render_phase_duration).toBe(RENDER_DURATION * 1e6) + expect(render.effect_phase_duration).toBe(EFFECT_DURATION * 1e6) + expect(render.layout_effect_phase_duration).toBe(LAYOUT_EFFECT_DURATION * 1e6) + }) + + it('should report update phase on re-render', async () => { + const { lifeCycle } = setupProfiler() + + let forceUpdate: () => void + + function App() { + const [, setState] = React.useState(0) + forceUpdate = () => setState((prev) => prev + 1) + return ( + + + + ) + } + + appendComponent() + + clock.tick(1) + + act(() => { + forceUpdate!() + }) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + const samples = getSamples(payload) + expect(samples.length).toBe(2) + expect(samples[1].renders[0].phase).toBe('update') + }) + + it('should not include base_duration', async () => { + const { lifeCycle } = setupProfiler() + + appendComponent( + + + + ) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + expect(getSamples(payload)[0].renders[0].base_duration).toBeUndefined() + }) + + it('should still render children', () => { + setupProfiler() + + const container = appendComponent( + +
+ + ) + + expect(container.querySelector('#child')).toBeTruthy() + }) + }) + + describe('sample batching', () => { + it('should group renders from the same React commit into one sample', async () => { + const { lifeCycle } = setupProfiler() + + appendComponent( + <> + + + + + + + + ) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + const samples = getSamples(payload) + expect(samples.length).toBe(1) + expect(samples[0].renders.length).toBe(2) + const components = samples[0].renders.map((r) => r.component) + expect(components).toContain('ComponentA') + expect(components).toContain('ComponentB') + }) + + it('should put renders from different React commits into separate samples', async () => { + const { lifeCycle } = setupProfiler() + + let forceUpdate: () => void + + function App() { + const [, setState] = React.useState(0) + forceUpdate = () => setState((prev) => prev + 1) + return ( + + + + ) + } + + appendComponent() + + act(() => { + forceUpdate!() + }) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + expect(getSamples(payload).length).toBe(2) + }) + }) + + describe('deduplication', () => { + it('should emit only one render per ReactProfiler per commit', async () => { + const { lifeCycle } = setupProfiler() + + appendComponent( + + + + ) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + expect(getSamples(payload)[0].renders.length).toBe(1) + }) + }) + + describe('session lifecycle', () => { + it('should stop profiling when session expires and flush pending data', async () => { + const { lifeCycle } = setupProfiler() + + appendComponent( + + + + ) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + await waitNextMicrotask() + await waitNextMicrotask() + expect(interceptor.requests.length).toBe(1) + }) + + it('should include session ID in the event', async () => { + const { lifeCycle } = setupProfiler() + + appendComponent( + + + + ) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + expect(payload.event.session?.id).toBe('session-id-1') + }) + + it('should include view ID and name in the event', async () => { + const { lifeCycle } = setupProfiler() + + appendComponent( + + + + ) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED, undefined as any) + + const payload = await getProfilePayload() + expect(payload.event.view?.id).toEqual(['view-id-1']) + expect(payload.event.view?.name).toEqual(['view-name-1']) + }) + }) +}) diff --git a/packages/rum-react/src/domain/performance/reactProfiler.tsx b/packages/rum-react/src/domain/performance/reactProfiler.tsx new file mode 100644 index 0000000000..0a9a3eaa5f --- /dev/null +++ b/packages/rum-react/src/domain/performance/reactProfiler.tsx @@ -0,0 +1,164 @@ +import React from 'react' +import type { Duration, RelativeTime } from '@datadog/browser-core' +import { getTimeStamp } from '@datadog/browser-core' +import { isReactProfilingRunning } from '../reactPlugin' +import { createTimer } from './timer' +import { collectReactComponentRender } from './collectReactComponentRender' + +/** + * Context attached to every `react_component_render` event, regardless of + * whether profiling mode is enabled or not. + */ +export interface ReactProfilerContext { + framework: 'react' + /** + * Whether this is the first render (mount) or a subsequent update. + * Matches React Profiler's `phase` values. In standard mode, `nested-update` is not + * distinguishable from `update` and will be reported as `update`. + */ + phase: 'mount' | 'update' | 'nested-update' + /** + * Time spent in the render phase. + */ + render_phase_duration?: number + /** + * Time spent in `useLayoutEffect`. + */ + layout_effect_phase_duration?: number + /** + * Time spent in `useEffect`. + */ + effect_phase_duration?: number + /** + * Estimated time to re-render this subtree from scratch (ignoring memoization). + * Only present in profiling mode. + */ + base_duration?: number +} + +/** + * Track the performance of a React component or subtree and report it as a + * `react_component_render` RUM event. + * + * Uses `` wrapping a sentinel-component approach. In standard production + * builds, `` is a no-op, so only the sentinel fires and emits an event + * with render/layoutEffect/effect phase durations. When the profiling build is used in + * production, `` fires first (during the layout phase) and emits a richer + * event including `base_duration` and accurate `nested-update` detection? + */ +export const ReactProfiler = ({ name, children }: { name: string; children?: React.ReactNode }) => { + if (!isReactProfilingRunning()) { + return <>{children} + } + + return ( + + {children} + + ) +} + +interface ProfilerComposedProps { + name: string + children?: React.ReactNode +} + +/** + * Composed mode — React.Profiler wraps the sentinel approach. + * React.Profiler fires during layout phase (before useEffect). + * Sentinel's onEffectEnd fires in useEffect (after layout). + */ +function Profiler({ name, children }: ProfilerComposedProps) { + const handleRender: React.ProfilerOnRenderCallback = React.useCallback( + (id, phase, actualDuration, baseDuration, startTime) => { + collectReactComponentRender({ + component: id, + startTime: getTimeStamp(startTime as RelativeTime), + duration: actualDuration as Duration, + phase, + baseDurationMs: baseDuration, + }) + }, + [] + ) + + return ( + + {children} + + ) +} + +interface StandardProfilerProps { + name: string + children?: React.ReactNode +} + +/** + * Sentinel mode — bracket the tracked subtree with LifeCycle components. + */ +function StandardProfiler({ name, children }: StandardProfilerProps) { + const isFirstRender = React.useRef(true) + + const renderTimer = createTimer() + const effectTimer = createTimer() + const layoutEffectTimer = createTimer() + + const onEffectEnd = () => { + const phase = isFirstRender.current ? ('mount' as const) : ('update' as const) + isFirstRender.current = false + + const startTime = renderTimer.getStartTime() + const renderDuration = renderTimer.getDuration() + const effectDuration = effectTimer.getDuration() + const layoutEffectDuration = layoutEffectTimer.getDuration() + + if (startTime === undefined) { + return + } + + collectReactComponentRender({ + component: name, + startTime, + duration: ((renderDuration ?? 0) + (effectDuration ?? 0) + (layoutEffectDuration ?? 0)) as Duration, + phase, + renderPhaseDuration: renderDuration ?? undefined, + layoutEffectPhaseDuration: layoutEffectDuration ?? undefined, + effectPhaseDuration: effectDuration ?? undefined, + }) + } + + 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/reactPlugin.ts b/packages/rum-react/src/domain/reactPlugin.ts index 4dfc0e82e4..c40a6ef395 100644 --- a/packages/rum-react/src/domain/reactPlugin.ts +++ b/packages/rum-react/src/domain/reactPlugin.ts @@ -1,8 +1,13 @@ import type { RumPlugin, RumPublicApi, StartRumResult } from '@datadog/browser-rum-core' +import { isSampled } from '@datadog/browser-rum-core' +import type { ReactProfilingController } from './createReactProfiler' +import { createReactProfiler } from './createReactProfiler' let globalPublicApi: RumPublicApi | undefined let globalConfiguration: ReactPluginConfiguration | undefined let globalAddError: StartRumResult['addError'] | undefined +let globalReactProfiler: ReactProfilingController | undefined + type InitSubscriber = (configuration: ReactPluginConfiguration, rumPublicApi: RumPublicApi) => void type StartSubscriber = (addError: StartRumResult['addError']) => void @@ -66,8 +71,18 @@ export function reactPlugin(configuration: ReactPluginConfiguration = {}): React initConfiguration.trackViewsManually = true } }, - onRumStart({ addError }) { + onRumStart({ addError, configuration: rumConfiguration, lifeCycle, session, viewHistory, createEncoder }) { globalAddError = addError + + if (rumConfiguration && lifeCycle && session && viewHistory && createEncoder) { + const sessionData = session.findTrackedSession() + if (sessionData && isSampled(sessionData.id, rumConfiguration.profilingSampleRate)) { + const profiler = createReactProfiler(rumConfiguration, lifeCycle, session, createEncoder, viewHistory) + globalReactProfiler = profiler + profiler.start() + } + } + for (const subscriber of onRumStartSubscribers) { if (addError) { subscriber(addError) @@ -96,10 +111,20 @@ export function onRumStart(callback: StartSubscriber) { } } +export function isReactProfilingRunning() { + return globalReactProfiler?.isRunning() ?? false +} + +export function getRunningReactProfiler(): ReactProfilingController | undefined { + return globalReactProfiler?.isRunning() ? globalReactProfiler : undefined +} + export function resetReactPlugin() { globalPublicApi = undefined globalConfiguration = undefined globalAddError = undefined + globalReactProfiler?.stop() + globalReactProfiler = undefined onRumInitSubscribers.length = 0 onRumStartSubscribers.length = 0 } diff --git a/packages/rum-react/src/entries/main.ts b/packages/rum-react/src/entries/main.ts index 8194078b55..ee9bcc4842 100644 --- a/packages/rum-react/src/entries/main.ts +++ b/packages/rum-react/src/entries/main.ts @@ -5,7 +5,8 @@ export type { ErrorBoundaryFallback, ErrorBoundaryProps } from '../domain/error' export type { ReactPluginConfiguration, ReactPlugin } from '../domain/reactPlugin' export { reactPlugin } from '../domain/reactPlugin' // eslint-disable-next-line camelcase -export { UNSTABLE_ReactComponentTracker } from '../domain/performance' +export { UNSTABLE_ReactComponentTracker, ReactProfiler } from '../domain/performance' +export type { ReactProfilerContext } from '../domain/performance' /** * @deprecated Use {@link ErrorBoundaryProps} instead. diff --git a/packages/rum-react/src/types/reactProfileTrace.ts b/packages/rum-react/src/types/reactProfileTrace.ts new file mode 100644 index 0000000000..a26862dfda --- /dev/null +++ b/packages/rum-react/src/types/reactProfileTrace.ts @@ -0,0 +1,54 @@ +/** + * DO NOT MODIFY IT BY HAND. Run `yarn json-schemas:sync` instead. + */ + +/** + * Trace attachment for a React profiling session. Contains render samples grouped by React commit batch. + */ +export interface ReactProfileTrace { + /** + * Render samples, one per React commit batch. + */ + readonly samples: { + /** + * ISO 8601 timestamp of the earliest render in this batch. + */ + readonly timestamp: string + /** + * Component renders that occurred in this commit batch. + */ + readonly renders: { + /** + * Name of the React component being profiled. + */ + readonly component: string + /** + * React render phase. + */ + readonly phase: 'mount' | 'update' | 'nested-update' + /** + * Total render duration in nanoseconds, including all lifecycle phases. + */ + readonly duration: number + /** + * Time spent in the render phase in nanoseconds. Only present in standard mode. + */ + readonly render_phase_duration?: number + /** + * Time spent in useLayoutEffect in nanoseconds. Only present in standard mode. + */ + readonly layout_effect_phase_duration?: number + /** + * Time spent in useEffect in nanoseconds. Only present in standard mode. + */ + readonly effect_phase_duration?: number + /** + * Estimated time to re-render the subtree from scratch in nanoseconds. Only present when using react-dom/profiling build. + */ + readonly base_duration?: number + [k: string]: unknown + }[] + [k: string]: unknown + }[] + [k: string]: unknown +} diff --git a/packages/rum-react/src/types/reactProfiling.ts b/packages/rum-react/src/types/reactProfiling.ts new file mode 100644 index 0000000000..6f58364c2e --- /dev/null +++ b/packages/rum-react/src/types/reactProfiling.ts @@ -0,0 +1,67 @@ +/** + * DO NOT MODIFY IT BY HAND. Run `yarn json-schemas:sync` instead. + */ + +/** + * Schema of Browser SDK React Profiling types. + */ +export type ReactProfiling = ReactProfileEvent +/** + * Schema for a React profiling session event, covering a time window of batched component render samples. + */ +export type ReactProfileEvent = ReactProfilingCommonProperties & { + /** + * ISO 8601 timestamp of when the profiling window started. + */ + readonly start: string + /** + * ISO 8601 timestamp of when the profiling window ended. + */ + readonly end: string + /** + * List of profile attachment filenames. + */ + readonly attachments: 'react-profiling.json'[] + [k: string]: unknown +} + +/** + * Schema of common properties for React profiling events. + */ +export interface ReactProfilingCommonProperties { + /** + * Application properties. + */ + readonly application: { + /** + * Application ID. + */ + readonly id: string + [k: string]: unknown + } + /** + * Session properties. + */ + readonly session?: { + /** + * Session ID. + */ + readonly id: string + [k: string]: unknown + } + /** + * View properties. + */ + readonly view?: { + /** + * Array of view IDs seen during the profiling window. + */ + readonly id: string[] + /** + * Array of view names seen during the profiling window. + */ + readonly name: string[] + [k: string]: unknown + } + [k: string]: unknown +} diff --git a/packages/rum-react/test/initializeReactPlugin.ts b/packages/rum-react/test/initializeReactPlugin.ts index a11d10a868..ce6cf5a461 100644 --- a/packages/rum-react/test/initializeReactPlugin.ts +++ b/packages/rum-react/test/initializeReactPlugin.ts @@ -1,29 +1,58 @@ -import type { RumInitConfiguration, RumPublicApi, StartRumResult } from '@datadog/browser-rum-core' +import type { Encoder, DeflateEncoderStreamId } from '@datadog/browser-core' import { noop } from '@datadog/browser-core' +import type { + LifeCycle, + RumConfiguration, + RumInitConfiguration, + RumPublicApi, + RumSessionManager, + StartRumResult, + ViewHistory, +} from '@datadog/browser-rum-core' import type { ReactPluginConfiguration } from '../src/domain/reactPlugin' import { reactPlugin, resetReactPlugin } from '../src/domain/reactPlugin' import { registerCleanupTask } from '../../core/test' +const MOCK_SESSION_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + export function initializeReactPlugin({ configuration = {}, initConfiguration = {}, publicApi = {}, addError = noop, + lifeCycle, + sessionManager, + viewHistory, + createEncoder, + rumConfiguration, }: { configuration?: ReactPluginConfiguration initConfiguration?: Partial publicApi?: Partial addError?: StartRumResult['addError'] + lifeCycle?: LifeCycle + sessionManager?: RumSessionManager + viewHistory?: ViewHistory + createEncoder?: (streamId: DeflateEncoderStreamId) => Encoder + rumConfiguration?: RumConfiguration } = {}) { resetReactPlugin() const plugin = reactPlugin(configuration) plugin.onInit({ - publicApi: publicApi as RumPublicApi, + publicApi: { + getInternalContext: () => ({ application_id: 'test-app', session_id: MOCK_SESSION_ID }), + ...publicApi, + } as RumPublicApi, initConfiguration: initConfiguration as RumInitConfiguration, }) plugin.onRumStart({ addError, + lifeCycle, + session: sessionManager, + viewHistory, + createEncoder, + configuration: rumConfiguration, }) registerCleanupTask(() => { diff --git a/rum-events-format b/rum-events-format index 5a80fb9a3c..d2f7ee74d9 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit 5a80fb9a3c054b28fba195fd301cadb094bccef8 +Subproject commit d2f7ee74d90811e2cfed77116bd6646c21a3f01e diff --git a/scripts/lib/generatedSchemaTypes.ts b/scripts/lib/generatedSchemaTypes.ts index d72eea7356..4039e8a4b6 100644 --- a/scripts/lib/generatedSchemaTypes.ts +++ b/scripts/lib/generatedSchemaTypes.ts @@ -29,4 +29,12 @@ export const SCHEMAS: SchemaConfig[] = [ typesPath: 'packages/rum-core/src/domain/configuration/remoteConfiguration.types.ts', schemaPath: 'remote-configuration/rum-sdk-config.json', }, + { + typesPath: 'packages/rum-react/src/types/reactProfiling.ts', + schemaPath: 'rum-events-format/schemas/react-profiling-browser-schema.json', + }, + { + typesPath: 'packages/rum-react/src/types/reactProfileTrace.ts', + schemaPath: 'rum-events-format/schemas/profiling/react/react-profile-trace-schema.json', + }, ] From ab8c298651704bd8db2295eb3ceb53fe72cf491d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=A9o=20Chaillou?= Date: Mon, 20 Apr 2026 10:29:05 +0200 Subject: [PATCH 2/2] test(profiling): add testing on react profiler on react-shoplist-like --- test/apps/react-shopist-like/package.json | 13 ++++ .../src/components/ProductCard.tsx | 3 + test/apps/react-shopist-like/src/main.tsx | 14 +++++ test/apps/react-shopist-like/yarn.lock | 61 +++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/test/apps/react-shopist-like/package.json b/test/apps/react-shopist-like/package.json index c8c5aa9cd6..a1a5c0a2f8 100644 --- a/test/apps/react-shopist-like/package.json +++ b/test/apps/react-shopist-like/package.json @@ -8,6 +8,8 @@ "preview": "vite preview" }, "dependencies": { + "@datadog/browser-rum": "*", + "@datadog/browser-rum-react": "*", "react": "19.2.4", "react-dom": "19.2.4", "react-router-dom": "7.13.0" @@ -19,5 +21,16 @@ "globals": "17.3.0", "typescript": "5.9.3", "vite": "7.3.1" + }, + "resolutions": { + "@datadog/browser-rum-core": "file:../../../packages/rum-core/package.tgz", + "@datadog/browser-core": "file:../../../packages/core/package.tgz", + "@datadog/browser-rum": "file:../../../packages/rum/package.tgz", + "@datadog/browser-rum-react": "file:../../../packages/rum-react/package.tgz", + "@datadog/browser-rum-slim": "file:../../../packages/rum-slim/package.tgz", + "@datadog/browser-worker": "file:../../../packages/worker/package.tgz" + }, + "volta": { + "extends": "../../../package.json" } } diff --git a/test/apps/react-shopist-like/src/components/ProductCard.tsx b/test/apps/react-shopist-like/src/components/ProductCard.tsx index 06f5612154..702b934524 100644 --- a/test/apps/react-shopist-like/src/components/ProductCard.tsx +++ b/test/apps/react-shopist-like/src/components/ProductCard.tsx @@ -1,4 +1,5 @@ import { Link } from 'react-router-dom' +import { ReactProfiler } from '@datadog/browser-rum-react' import { Product } from '../data/products' interface ProductCardProps { @@ -8,6 +9,7 @@ interface ProductCardProps { export function ProductCard({ product }: ProductCardProps) { return ( +
{product.name} @@ -18,5 +20,6 @@ export function ProductCard({ product }: ProductCardProps) {
${product.price.toFixed(2)}
+
) } diff --git a/test/apps/react-shopist-like/src/main.tsx b/test/apps/react-shopist-like/src/main.tsx index a436f98255..f121ae182c 100644 --- a/test/apps/react-shopist-like/src/main.tsx +++ b/test/apps/react-shopist-like/src/main.tsx @@ -1,7 +1,21 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { datadogRum } from '@datadog/browser-rum' +import { reactPlugin } from '@datadog/browser-rum-react' import App from './App.tsx' +datadogRum.init({ + applicationId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + clientToken: 'pubxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + site: 'datadoghq.com', + service: 'react-shopist', + sessionSampleRate: 100, + profilingSampleRate: 100, + compressIntakeRequests: false, + trackUserInteractions: true, + plugins: [reactPlugin()], +}) + createRoot(document.getElementById('root')!).render( diff --git a/test/apps/react-shopist-like/yarn.lock b/test/apps/react-shopist-like/yarn.lock index 46358377df..1668d3350e 100644 --- a/test/apps/react-shopist-like/yarn.lock +++ b/test/apps/react-shopist-like/yarn.lock @@ -209,6 +209,65 @@ __metadata: languageName: node linkType: hard +"@datadog/browser-core@file:../../../packages/core/package.tgz::locator=react-shopist%40workspace%3A.": + version: 6.32.0 + resolution: "@datadog/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=9df5f7&locator=react-shopist%40workspace%3A." + checksum: 10c0/3a1ccffb95ea4a7d21d47a355ec10477406ac5c74d9a5e293d48d38a67ca2e08ecf271df9da46edbc3f82412182eae9e229127ce6dbe96a4ab51fe358e8da989 + languageName: node + linkType: hard + +"@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=react-shopist%40workspace%3A.": + version: 6.32.0 + resolution: "@datadog/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=2d3348&locator=react-shopist%40workspace%3A." + dependencies: + "@datadog/browser-core": "npm:6.32.0" + checksum: 10c0/5b8924dbdba0cdbe25c9bc9516cdd5c57ebfc3c2d8a23428ac6dac6bcf2dd71682a5702175805509c3ec73e94069d865bca1bd1c4abfefb51a46cc1bbbdee74c + languageName: node + linkType: hard + +"@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=react-shopist%40workspace%3A.": + version: 6.32.0 + resolution: "@datadog/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=4bac83&locator=react-shopist%40workspace%3A." + dependencies: + "@datadog/browser-core": "npm:6.32.0" + "@datadog/browser-rum-core": "npm:6.32.0" + peerDependencies: + "@tanstack/react-router": ">=1.64.0 <2" + react: 18 || 19 + react-router: 6 || 7 + react-router-dom: 6 || 7 + peerDependenciesMeta: + "@datadog/browser-rum": + optional: true + "@datadog/browser-rum-slim": + optional: true + "@tanstack/react-router": + optional: true + react: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + checksum: 10c0/21d8b73636de3233306bd052af81a1b6f466e1576235558e5a74b5bc6adeb387b3eddbf34e01551ce3ffd356a09d2d2435d736ea0ec4c93d95ea8489f6d6ef47 + languageName: node + linkType: hard + +"@datadog/browser-rum@file:../../../packages/rum/package.tgz::locator=react-shopist%40workspace%3A.": + version: 6.26.0 + resolution: "@datadog/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=e63302&locator=react-shopist%40workspace%3A." + dependencies: + "@datadog/browser-core": "npm:6.26.0" + "@datadog/browser-rum-core": "npm:6.26.0" + peerDependencies: + "@datadog/browser-logs": 6.26.0 + peerDependenciesMeta: + "@datadog/browser-logs": + optional: true + checksum: 10c0/0ea8df9b0be160b29aba10aebe8a0ace321c12269c121db7627192f5bcad555880aee413c10ed642a7af498e05c8e044aef1c9b45753d5899753a018dbd62e82 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.27.2": version: 0.27.2 resolution: "@esbuild/aix-ppc64@npm:0.27.2" @@ -1427,6 +1486,8 @@ __metadata: version: 0.0.0-use.local resolution: "react-shopist@workspace:." dependencies: + "@datadog/browser-rum": "npm:*" + "@datadog/browser-rum-react": "npm:*" "@types/react": "npm:19.2.14" "@types/react-dom": "npm:19.2.3" "@vitejs/plugin-react": "npm:5.1.4"