diff --git a/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx b/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx index 106ce30f71..df91575445 100644 --- a/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx +++ b/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx @@ -35,6 +35,7 @@ const RUM_EVENT_TYPE_COLOR = { telemetry: 'teal', vital: 'orange', transition: 'green', + view_update: 'blue', } const LOG_STATUS_COLOR = { diff --git a/packages/core/src/domain/telemetry/telemetryEvent.types.ts b/packages/core/src/domain/telemetry/telemetryEvent.types.ts index ffb16263a1..81033d1c71 100644 --- a/packages/core/src/domain/telemetry/telemetryEvent.types.ts +++ b/packages/core/src/domain/telemetry/telemetryEvent.types.ts @@ -528,6 +528,7 @@ export type TelemetryCommonFeaturesUsage = | AddFeatureFlagEvaluation | AddOperationStepVital | GraphQLRequest + | AddViewLoadingTime /** * Schema of browser specific features usage */ @@ -543,7 +544,7 @@ export type TelemetryBrowserFeaturesUsage = /** * Schema of mobile specific features usage */ -export type TelemetryMobileFeaturesUsage = AddViewLoadingTime | TrackWebView | AndroidNetworkInstrumentation +export type TelemetryMobileFeaturesUsage = TrackWebView | AndroidNetworkInstrumentation /** * Schema of common properties of Telemetry events @@ -886,6 +887,25 @@ export interface GraphQLRequest { feature: 'graphql-request' [k: string]: unknown } +export interface AddViewLoadingTime { + /** + * addViewLoadingTime API + */ + feature: 'addViewLoadingTime' + /** + * Whether the view is not available + */ + no_view?: boolean + /** + * Whether the available view is not active + */ + no_active_view?: boolean + /** + * Whether this call overwrote a previously set loading time + */ + overwritten?: boolean + [k: string]: unknown +} export interface StartSessionReplayRecording { /** * startSessionReplayRecording API @@ -946,25 +966,6 @@ export interface StopResource { feature: 'stop-resource' [k: string]: unknown } -export interface AddViewLoadingTime { - /** - * addViewLoadingTime API - */ - feature: 'addViewLoadingTime' - /** - * Whether the view is not available - */ - no_view: boolean - /** - * Whether the available view is not active - */ - no_active_view: boolean - /** - * Whether the loading time was overwritten - */ - overwritten: boolean - [k: string]: unknown -} export interface TrackWebView { /** * trackWebView API diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 1b9638db8f..deb8ab34c8 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -608,6 +608,33 @@ describe('preStartRum', () => { expect(addTimingSpy).toHaveBeenCalledOnceWith(name, time) }) + it('setLoadingTime', () => { + const setLoadingTimeSpy = jasmine.createSpy() + doStartRumSpy.and.returnValue({ setLoadingTime: setLoadingTimeSpy } as unknown as StartRumResult) + + const timestamp = 123 as TimeStamp + strategy.setLoadingTime(timestamp) + strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + expect(setLoadingTimeSpy).toHaveBeenCalledOnceWith(timestamp) + }) + + it('setLoadingTime should preserve call timestamp', () => { + const clock = mockClock() + const setLoadingTimeSpy = jasmine.createSpy() + doStartRumSpy.and.returnValue({ setLoadingTime: setLoadingTimeSpy } as unknown as StartRumResult) + + clock.tick(10) + strategy.setLoadingTime(clock.timeStamp(10)) + + clock.tick(20) + strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + + expect(setLoadingTimeSpy).toHaveBeenCalledOnceWith(jasmine.any(Number)) + // Verify the timestamp was captured at call time (tick 10), not at drain time (tick 30) + const capturedTimestamp = setLoadingTimeSpy.calls.argsFor(0)[0] as number + expect(capturedTimestamp).toBe(clock.timeStamp(10)) + }) + it('setViewContext', () => { const setViewContextSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ setViewContext: setViewContextSpy } as unknown as StartRumResult) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index b7cfd8422d..58cf5ad3e4 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -5,6 +5,7 @@ import type { ContextManager, BoundedBuffer, Telemetry, + TimeStamp, } from '@datadog/browser-core' import { createBoundedBuffer, @@ -241,6 +242,10 @@ export function createPreStartStrategy( bufferApiCalls.add((startRumResult) => startRumResult.addTiming(name, time)) }, + setLoadingTime: ((callTimestamp: TimeStamp) => { + bufferApiCalls.add((startRumResult) => startRumResult.setLoadingTime(callTimestamp)) + }) as Strategy['setLoadingTime'], + startView(options, startClocks = clocksNow()) { const callback = (startRumResult: StartRumResult) => { startRumResult.startView(options, startClocks) diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 1bb7c97a20..1640c99ee4 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -24,6 +24,7 @@ const noopStartRum = (): ReturnType => ({ addError: () => undefined, addEvent: () => undefined, addTiming: () => undefined, + setLoadingTime: () => undefined, addFeatureFlagEvaluation: () => undefined, startView: () => undefined, setViewContext: () => undefined, @@ -555,6 +556,34 @@ describe('rum public api', () => { }) }) + describe('setViewLoadingTime', () => { + let setLoadingTimeSpy: jasmine.Spy['setLoadingTime']> + let rumPublicApi: RumPublicApi + + beforeEach(() => { + setLoadingTimeSpy = jasmine.createSpy() + ;({ rumPublicApi } = makeRumPublicApiWithDefaults({ + startRumResult: { + setLoadingTime: setLoadingTimeSpy, + }, + })) + }) + + it('should call setLoadingTime with timestamp', () => { + rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + + rumPublicApi.setViewLoadingTime() + + expect(setLoadingTimeSpy).toHaveBeenCalledOnceWith(jasmine.any(Number)) + }) + + it('should not throw when called before init', () => { + expect(() => rumPublicApi.setViewLoadingTime()).not.toThrow() + + expect(setLoadingTimeSpy).not.toHaveBeenCalled() + }) + }) + describe('addFeatureFlagEvaluation', () => { let addFeatureFlagEvaluationSpy: jasmine.Spy['addFeatureFlagEvaluation']> let displaySpy: jasmine.Spy<() => void> diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 57e93cf128..d1d10a5cfb 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -37,6 +37,7 @@ import { ExperimentalFeature, mockable, generateUUID, + timeStampNow, } from '@datadog/browser-core' import type { LifeCycle } from '../domain/lifeCycle' @@ -236,6 +237,16 @@ export interface RumPublicApi extends PublicApi { */ addTiming: (name: string, time?: number) => void + /** + * [Experimental] Manually set the current view's loading time. + * + * Call this method when the view has finished loading. The loading time is computed as the + * elapsed time since the view started. Each call replaces any previously set value (last-call-wins). + * + * @category Data Collection + */ + setViewLoadingTime: () => void + /** * Set the global context information to all events, stored in `@context` * See [Global context](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/#global-context) for further information. @@ -553,6 +564,7 @@ export interface Strategy { getInternalContext: StartRumResult['getInternalContext'] stopSession: StartRumResult['stopSession'] addTiming: StartRumResult['addTiming'] + setLoadingTime: StartRumResult['setLoadingTime'] startView: StartRumResult['startView'] setViewName: StartRumResult['setViewName'] @@ -773,6 +785,14 @@ export function makeRumPublicApi( strategy.addTiming(sanitize(name)!, time as RelativeTime | TimeStamp | undefined) }), + setViewLoadingTime: monitor(() => { + const callTimestamp = timeStampNow() + strategy.setLoadingTime(callTimestamp) + addTelemetryUsage({ + feature: 'addViewLoadingTime', + }) + }), + setGlobalContext: defineContextMethod( getStrategy, CustomerContextKey.globalContext, diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index f278bbe4e3..8fb5b86d60 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -202,6 +202,7 @@ export function startRumEventCollection( const { addTiming, + setLoadingTime, startView, setViewName, setViewContext, @@ -253,6 +254,7 @@ export function startRumEventCollection( addEvent: eventCollection.addEvent, addError, addTiming, + setLoadingTime, addFeatureFlagEvaluation: featureFlagContexts.addFeatureFlagEvaluation, startView, setViewContext, diff --git a/packages/rum-core/src/domain/view/setupViewTest.specHelper.ts b/packages/rum-core/src/domain/view/setupViewTest.specHelper.ts index edb7313aad..34d36159ed 100644 --- a/packages/rum-core/src/domain/view/setupViewTest.specHelper.ts +++ b/packages/rum-core/src/domain/view/setupViewTest.specHelper.ts @@ -45,16 +45,24 @@ export function setupViewTest( } = spyOnViews() lifeCycle.subscribe(LifeCycleEventType.VIEW_ENDED, viewEndHandler) - const { stop, startView, setViewName, setViewContext, setViewContextProperty, getViewContext, addTiming } = - trackViews( - lifeCycle, - domMutationObservable, - windowOpenObservable, - configuration, - locationChangeObservable, - !configuration.trackViewsManually, - initialViewOptions - ) + const { + stop, + startView, + setViewName, + setViewContext, + setViewContextProperty, + getViewContext, + addTiming, + setLoadingTime, + } = trackViews( + lifeCycle, + domMutationObservable, + windowOpenObservable, + configuration, + locationChangeObservable, + !configuration.trackViewsManually, + initialViewOptions + ) return { stop, startView, @@ -64,6 +72,7 @@ export function setupViewTest( changeLocation, setViewName, addTiming, + setLoadingTime, getViewUpdate, getViewUpdateCount, getViewCreate, diff --git a/packages/rum-core/src/domain/view/trackViews.spec.ts b/packages/rum-core/src/domain/view/trackViews.spec.ts index 50a29d3c35..c8b1efb680 100644 --- a/packages/rum-core/src/domain/view/trackViews.spec.ts +++ b/packages/rum-core/src/domain/view/trackViews.spec.ts @@ -781,6 +781,206 @@ describe('view custom timings', () => { }) }) +describe('manual loading time', () => { + const lifeCycle = new LifeCycle() + let clock: Clock + let viewTest: ViewTest + + beforeEach(() => { + clock = mockClock() + viewTest = setupViewTest({ lifeCycle }) + + registerCleanupTask(() => { + viewTest.stop() + }) + }) + + it('should set loading time on the current view', () => { + const { getViewUpdate, getViewUpdateCount, setLoadingTime } = viewTest + + clock.tick(500) + setLoadingTime() + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + const lastUpdate = getViewUpdate(getViewUpdateCount() - 1) + expect(lastUpdate.commonViewMetrics.loadingTime).toBe(clock.relative(500)) + }) + + it('should overwrite loading time on subsequent calls (last-call-wins)', () => { + const { getViewUpdate, getViewUpdateCount, setLoadingTime } = viewTest + + clock.tick(100) + setLoadingTime() + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + const firstValue = getViewUpdate(getViewUpdateCount() - 1).commonViewMetrics.loadingTime + + clock.tick(200) + setLoadingTime() + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + const lastUpdate = getViewUpdate(getViewUpdateCount() - 1) + expect(lastUpdate.commonViewMetrics.loadingTime).not.toBe(firstValue) + }) + + it('should not set loading time when the session has expired', () => { + clock.tick(0) // run immediate timeouts (mostly for `trackNavigationTimings`) + const { getViewUpdateCount, setLoadingTime } = viewTest + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + + const previousCount = getViewUpdateCount() + + setLoadingTime() + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdateCount()).toBe(previousCount) + }) + + it('should compute loading time relative to route-change view start', () => { + const { getViewUpdate, getViewUpdateCount, startView, setLoadingTime } = viewTest + + clock.tick(2000) + startView() + + clock.tick(500) + setLoadingTime() + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + const lastUpdate = getViewUpdate(getViewUpdateCount() - 1) + expect(lastUpdate.loadingType).toBe(ViewLoadingType.ROUTE_CHANGE) + expect(lastUpdate.commonViewMetrics.loadingTime).toBe(500 as Duration) + }) + + it('should suppress auto-detected loading time after manual call', () => { + const { getViewUpdate, getViewUpdateCount, setLoadingTime } = viewTest + + clock.tick(100) + setLoadingTime() + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + const manualValue = getViewUpdate(getViewUpdateCount() - 1).commonViewMetrics.loadingTime + + clock.tick(PAGE_ACTIVITY_END_DELAY) + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + const lastUpdate = getViewUpdate(getViewUpdateCount() - 1) + expect(lastUpdate.commonViewMetrics.loadingTime).toBe(manualValue) + }) + + it('should start with clean loading time state on new view', () => { + const { getViewUpdate, getViewUpdateCount, startView, setLoadingTime } = viewTest + + clock.tick(100) + setLoadingTime() + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + startView() + + clock.tick(200) + setLoadingTime() + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + const lastUpdate = getViewUpdate(getViewUpdateCount() - 1) + expect(lastUpdate.loadingType).toBe(ViewLoadingType.ROUTE_CHANGE) + expect(lastUpdate.commonViewMetrics.loadingTime).toBeDefined() + }) + + it('should trigger a view update after setLoadingTime', () => { + const { getViewUpdateCount, setLoadingTime } = viewTest + + const countBefore = getViewUpdateCount() + + setLoadingTime() + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdateCount()).toBeGreaterThan(countBefore) + }) + + it('should stop auto-detection tracking after first manual loading time', () => { + const { getViewUpdate, getViewUpdateCount, setLoadingTime } = viewTest + + clock.tick(100) + setLoadingTime() // first call -- should stop auto-detection tracking + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + const firstValue = getViewUpdate(getViewUpdateCount() - 1).commonViewMetrics.loadingTime + + clock.tick(200) + setLoadingTime() // second call + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + const secondValue = getViewUpdate(getViewUpdateCount() - 1).commonViewMetrics.loadingTime + + // Let page activity end fire (would set auto-detected loading time if tracking wasn't stopped) + clock.tick(PAGE_ACTIVITY_END_DELAY) + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + // Auto-detection should not have replaced the second value + const finalUpdate = getViewUpdate(getViewUpdateCount() - 1) + expect(finalUpdate.commonViewMetrics.loadingTime).toBe(secondValue) + expect(finalUpdate.commonViewMetrics.loadingTime).not.toBe(firstValue) + }) + + it('should replace loading time on a route-change view with correct elapsed time', () => { + const { getViewUpdate, getViewUpdateCount, startView, setLoadingTime } = viewTest + + clock.tick(2000) // 2s into session + startView() // new route-change view starts + + clock.tick(300) // 300ms into new view + setLoadingTime() // first manual set: 300ms from view start + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + const firstValue = getViewUpdate(getViewUpdateCount() - 1).commonViewMetrics.loadingTime + + clock.tick(200) // 200ms later (500ms + THROTTLE total from view start) + setLoadingTime() // second call replaces previous value + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + const lastUpdate = getViewUpdate(getViewUpdateCount() - 1) + expect(lastUpdate.loadingType).toBe(ViewLoadingType.ROUTE_CHANGE) + expect(lastUpdate.commonViewMetrics.loadingTime).not.toBe(firstValue) + // Loading time should be relative to view start, not time origin + // Value = 300 + THROTTLE_VIEW_UPDATE_PERIOD + 200 (all ms from view start) + expect(lastUpdate.commonViewMetrics.loadingTime).toBe((300 + THROTTLE_VIEW_UPDATE_PERIOD + 200) as Duration) + }) + + it('should allow multiple calls, each replacing the previous value (last-call-wins)', () => { + const { getViewUpdate, getViewUpdateCount, setLoadingTime } = viewTest + + clock.tick(100) + setLoadingTime() // first call: 100ms + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + const firstValue = getViewUpdate(getViewUpdateCount() - 1).commonViewMetrics.loadingTime + + clock.tick(200) + setLoadingTime() // second call + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + const secondValue = getViewUpdate(getViewUpdateCount() - 1).commonViewMetrics.loadingTime + + clock.tick(300) + setLoadingTime() // third call + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + const thirdValue = getViewUpdate(getViewUpdateCount() - 1).commonViewMetrics.loadingTime + + // Each value should be larger than the previous (more time elapsed) + expect(secondValue).toBeGreaterThan(firstValue as number) + expect(thirdValue).toBeGreaterThan(secondValue as number) + // Final value should be the cumulative elapsed time from time origin (initial view uses clocksOrigin) + expect(thirdValue).toBe(clock.relative(100 + THROTTLE_VIEW_UPDATE_PERIOD + 200 + THROTTLE_VIEW_UPDATE_PERIOD + 300)) + }) +}) + describe('start view', () => { const lifeCycle = new LifeCycle() let clock: Clock diff --git a/packages/rum-core/src/domain/view/trackViews.ts b/packages/rum-core/src/domain/view/trackViews.ts index f2d10f6e01..3cbcb8d223 100644 --- a/packages/rum-core/src/domain/view/trackViews.ts +++ b/packages/rum-core/src/domain/view/trackViews.ts @@ -179,6 +179,7 @@ export function trackViews( addTiming: (name: string, time: RelativeTime | TimeStamp = timeStampNow()) => { currentView.addTiming(name, time) }, + setLoadingTime: (callTimestamp?: TimeStamp) => currentView.setLoadingTime(callTimestamp), startView: (options?: ViewOptions, startClocks?: ClocksState) => { currentView.end({ endClocks: startClocks }) currentView = startNewView(ViewLoadingType.ROUTE_CHANGE, startClocks, options) @@ -259,6 +260,7 @@ function newView( stop: stopCommonViewMetricsTracking, stopINPTracking, getCommonViewMetrics, + setLoadingTime, } = trackCommonViewMetrics( lifeCycle, domMutationObservable, @@ -379,6 +381,7 @@ function newView( customTimings[sanitizeTiming(name)] = relativeTime scheduleViewUpdate() }, + setLoadingTime, setViewName(updatedName: string) { name = updatedName triggerViewUpdate() diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackCommonViewMetrics.spec.ts b/packages/rum-core/src/domain/view/viewMetrics/trackCommonViewMetrics.spec.ts new file mode 100644 index 0000000000..d7b50621be --- /dev/null +++ b/packages/rum-core/src/domain/view/viewMetrics/trackCommonViewMetrics.spec.ts @@ -0,0 +1,69 @@ +import type { Duration } from '@datadog/browser-core' +import { clocksOrigin, Observable } from '@datadog/browser-core' +import type { Clock } from '@datadog/browser-core/test' +import { mockClock, registerCleanupTask } from '@datadog/browser-core/test' +import { ViewLoadingType } from '../../../rawRumEvent.types' +import { createMutationRecord, mockGlobalPerformanceBuffer, mockRumConfiguration } from '../../../../test' +import { PAGE_ACTIVITY_END_DELAY, PAGE_ACTIVITY_VALIDATION_DELAY } from '../../waitPageActivityEnd' +import { LifeCycle } from '../../lifeCycle' +import type { RumMutationRecord } from '../../../browser/domMutationObservable' +import { trackCommonViewMetrics } from './trackCommonViewMetrics' + +const BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY = (PAGE_ACTIVITY_VALIDATION_DELAY * 0.8) as Duration +const AFTER_PAGE_ACTIVITY_END_DELAY = PAGE_ACTIVITY_END_DELAY * 1.1 + +// Tests trackCommonViewMetrics in isolation to verify loading time edge cases without the full view lifecycle setup +describe('trackCommonViewMetrics', () => { + const lifeCycle = new LifeCycle() + let clock: Clock + let domMutationObservable: Observable + let windowOpenObservable: Observable + let scheduleViewUpdateSpy: jasmine.Spy + + beforeEach(() => { + mockGlobalPerformanceBuffer() + clock = mockClock() + domMutationObservable = new Observable() + windowOpenObservable = new Observable() + scheduleViewUpdateSpy = jasmine.createSpy('scheduleViewUpdate') + }) + + describe('manual loading time suppresses auto-detected loading time callback', () => { + it('should ignore auto-detected loading time when manual loading time was already set', () => { + const { setLoadEvent, setLoadingTime, getCommonViewMetrics, stop } = trackCommonViewMetrics( + lifeCycle, + domMutationObservable, + windowOpenObservable, + mockRumConfiguration(), + scheduleViewUpdateSpy, + ViewLoadingType.INITIAL_LOAD, + clocksOrigin() + ) + + registerCleanupTask(stop) + + // Step 1: Trigger page activity and let it end. + // This sets isWaitingForActivityLoadingTime = false with a candidate, + // but isWaitingForLoadEvent is still true so the callback does not fire yet. + clock.tick(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) + domMutationObservable.notify([createMutationRecord()]) + clock.tick(AFTER_PAGE_ACTIVITY_END_DELAY) + + // Step 2: Set manual loading time via setLoadingTime. This sets hasManualLoadingTime = true + // and calls stopLoadingTimeTracking (waitPageActivityEnd already completed). + setLoadingTime() + const manualLoadingTime = getCommonViewMetrics().loadingTime + + expect(manualLoadingTime).toBeDefined() + + // Step 3: Fire setLoadEvent, which completes the remaining wait condition. + // trackLoadingTime's invokeCallbackIfAllCandidatesAreReceived will now fire + // the loading time callback. The hasManualLoadingTime guard should prevent + // the auto-detected value from overwriting the manual one. + setLoadEvent(200 as Duration) + + // Verify the manual value was preserved (not overwritten by auto-detection) + expect(getCommonViewMetrics().loadingTime).toBe(manualLoadingTime) + }) + }) +}) diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackCommonViewMetrics.ts b/packages/rum-core/src/domain/view/viewMetrics/trackCommonViewMetrics.ts index 4f29990ccd..5e1d96c845 100644 --- a/packages/rum-core/src/domain/view/viewMetrics/trackCommonViewMetrics.ts +++ b/packages/rum-core/src/domain/view/viewMetrics/trackCommonViewMetrics.ts @@ -1,4 +1,5 @@ -import type { ClocksState, Duration, Observable } from '@datadog/browser-core' +import type { ClocksState, Duration, Observable, RelativeTime, TimeStamp } from '@datadog/browser-core' +import { elapsed, timeStampNow } from '@datadog/browser-core' import type { ViewLoadingType } from '../../../rawRumEvent.types' import type { RumConfiguration } from '../../configuration' import type { LifeCycle } from '../../lifeCycle' @@ -28,6 +29,8 @@ export function trackCommonViewMetrics( viewStart: ClocksState ) { const commonViewMetrics: CommonViewMetrics = {} + let hasManualLoadingTime = false + let viewEnded = false const { stop: stopLoadingTimeTracking, setLoadEvent } = trackLoadingTime( lifeCycle, @@ -37,8 +40,10 @@ export function trackCommonViewMetrics( loadingType, viewStart, (newLoadingTime) => { - commonViewMetrics.loadingTime = newLoadingTime - scheduleViewUpdate() + if (!hasManualLoadingTime) { + commonViewMetrics.loadingTime = newLoadingTime + scheduleViewUpdate() + } } ) @@ -58,7 +63,7 @@ export function trackCommonViewMetrics( const { stop: stopINPTracking, getInteractionToNextPaint, - setViewEnd, + setViewEnd: setINPViewEnd, } = trackInteractionToNextPaint(configuration, viewStart.relative, loadingType) return { @@ -69,10 +74,25 @@ export function trackCommonViewMetrics( }, stopINPTracking, setLoadEvent, - setViewEnd, + setViewEnd: (viewEndTime: RelativeTime) => { + viewEnded = true + setINPViewEnd(viewEndTime) + }, getCommonViewMetrics: () => { commonViewMetrics.interactionToNextPaint = getInteractionToNextPaint() return commonViewMetrics }, + setLoadingTime: (callTimestamp?: TimeStamp) => { + if (viewEnded) { + return + } + const loadingTime = elapsed(viewStart.timeStamp, callTimestamp ?? timeStampNow()) + if (!hasManualLoadingTime) { + stopLoadingTimeTracking() + } + hasManualLoadingTime = true + commonViewMetrics.loadingTime = loadingTime + scheduleViewUpdate() + }, } } diff --git a/packages/rum-core/src/rumEvent.types.ts b/packages/rum-core/src/rumEvent.types.ts index b71348baa5..a99bf6c7fe 100644 --- a/packages/rum-core/src/rumEvent.types.ts +++ b/packages/rum-core/src/rumEvent.types.ts @@ -12,6 +12,7 @@ export type RumEvent = | RumLongTaskEvent | RumResourceEvent | RumViewEvent + | RumViewUpdateEvent | RumVitalEvent /** * Schema of all properties of an Action event @@ -956,317 +957,23 @@ export type RumResourceEvent = CommonProperties & */ export type RumViewEvent = CommonProperties & ViewContainerSchema & - StreamSchema & { + StreamSchema & + ViewProperties & { /** * RUM event type */ readonly type: 'view' - /** - * View properties - */ - readonly view: { - /** - * Duration in ns to the view is considered loaded - */ - readonly loading_time?: number - /** - * Duration in ns from the moment the view was started until all the initial network requests settled - */ - readonly network_settled_time?: number - /** - * Duration in ns to from the last interaction on previous view to the moment the current view was displayed - */ - readonly interaction_to_next_view_time?: number - /** - * Type of the loading of the view - */ - readonly loading_type?: - | 'initial_load' - | 'route_change' - | 'activity_display' - | 'activity_redisplay' - | 'fragment_display' - | 'fragment_redisplay' - | 'view_controller_display' - | 'view_controller_redisplay' - /** - * Time spent on the view in ns - */ - readonly time_spent: number - /** - * @deprecated - * Duration in ns to the first rendering (deprecated in favor of `view.performance.fcp.timestamp`) - */ - readonly first_contentful_paint?: number - /** - * @deprecated - * Duration in ns to the largest contentful paint (deprecated in favor of `view.performance.lcp.timestamp`) - */ - readonly largest_contentful_paint?: number - /** - * @deprecated - * CSS selector path of the largest contentful paint element (deprecated in favor of `view.performance.lcp.target_selector`) - */ - readonly largest_contentful_paint_target_selector?: string - /** - * @deprecated - * Duration in ns of the first input event delay (deprecated in favor of `view.performance.fid.duration`) - */ - readonly first_input_delay?: number - /** - * @deprecated - * Duration in ns to the first input (deprecated in favor of `view.performance.fid.timestamp`) - */ - readonly first_input_time?: number - /** - * @deprecated - * CSS selector path of the first input target element (deprecated in favor of `view.performance.fid.target_selector`) - */ - readonly first_input_target_selector?: string - /** - * @deprecated - * Longest duration in ns between an interaction and the next paint (deprecated in favor of `view.performance.inp.duration`) - */ - readonly interaction_to_next_paint?: number - /** - * @deprecated - * Duration in ns between start of the view and start of the INP (deprecated in favor of `view.performance.inp.timestamp`) - */ - readonly interaction_to_next_paint_time?: number - /** - * @deprecated - * CSS selector path of the interacted element corresponding to INP (deprecated in favor of `view.performance.inp.target_selector`) - */ - readonly interaction_to_next_paint_target_selector?: string - /** - * @deprecated - * Total layout shift score that occurred on the view (deprecated in favor of `view.performance.cls.score`) - */ - readonly cumulative_layout_shift?: number - /** - * @deprecated - * Duration in ns between start of the view and start of the largest layout shift contributing to CLS (deprecated in favor of `view.performance.cls.timestamp`) - */ - readonly cumulative_layout_shift_time?: number - /** - * @deprecated - * CSS selector path of the first element (in document order) of the largest layout shift contributing to CLS (deprecated in favor of `view.performance.cls.target_selector`) - */ - readonly cumulative_layout_shift_target_selector?: string - /** - * Duration in ns to the complete parsing and loading of the document and its sub resources - */ - readonly dom_complete?: number - /** - * Duration in ns to the complete parsing and loading of the document without its sub resources - */ - readonly dom_content_loaded?: number - /** - * Duration in ns to the end of the parsing of the document - */ - readonly dom_interactive?: number - /** - * Duration in ns to the end of the load event handler execution - */ - readonly load_event?: number - /** - * Duration in ns to the response start of the document request - */ - readonly first_byte?: number - /** - * User custom timings of the view. As timing name is used as facet path, it must contain only letters, digits, or the characters - _ . @ $ - */ - readonly custom_timings?: { - [k: string]: number - } - /** - * Whether the View corresponding to this event is considered active - */ - readonly is_active?: boolean - /** - * Whether the View had a low average refresh rate - */ - readonly is_slow_rendered?: boolean - /** - * Properties of the actions of the view - */ - readonly action: { - /** - * Number of actions that occurred on the view - */ - readonly count: number - [k: string]: unknown - } - /** - * Properties of the errors of the view - */ - readonly error: { - /** - * Number of errors that occurred on the view - */ - readonly count: number - [k: string]: unknown - } - /** - * Properties of the crashes of the view - */ - readonly crash?: { - /** - * Number of crashes that occurred on the view - */ - readonly count: number + view: { + time_spent: number + action: { [k: string]: unknown } - /** - * Properties of the long tasks of the view - */ - readonly long_task?: { - /** - * Number of long tasks that occurred on the view - */ - readonly count: number + error: { [k: string]: unknown } - /** - * Properties of the frozen frames of the view - */ - readonly frozen_frame?: { - /** - * Number of frozen frames that occurred on the view - */ - readonly count: number + resource: { [k: string]: unknown } - /** - * List of slow frames during the view’s lifetime - */ - readonly slow_frames?: { - /** - * Duration in ns between start of the view and the start of the slow frame - */ - readonly start: number - /** - * Duration in ns of the slow frame - */ - readonly duration: number - [k: string]: unknown - }[] - /** - * Properties of the resources of the view - */ - readonly resource: { - /** - * Number of resources that occurred on the view - */ - readonly count: number - [k: string]: unknown - } - /** - * Properties of the frustrations of the view - */ - readonly frustration?: { - /** - * Number of frustrations that occurred on the view - */ - readonly count: number - [k: string]: unknown - } - /** - * List of the periods of time the user had the view in foreground (focused in the browser) - */ - readonly in_foreground_periods?: { - /** - * Duration in ns between start of the view and start of foreground period - */ - readonly start: number - /** - * Duration in ns of the view foreground period - */ - readonly duration: number - [k: string]: unknown - }[] - /** - * Average memory used during the view lifetime (in bytes) - */ - readonly memory_average?: number - /** - * Peak memory used during the view lifetime (in bytes) - */ - readonly memory_max?: number - /** - * Total number of cpu ticks during the view’s lifetime - */ - readonly cpu_ticks_count?: number - /** - * Average number of cpu ticks per second during the view’s lifetime - */ - readonly cpu_ticks_per_second?: number - /** - * Average refresh rate during the view’s lifetime (in frames per second) - */ - readonly refresh_rate_average?: number - /** - * Minimum refresh rate during the view’s lifetime (in frames per second) - */ - readonly refresh_rate_min?: number - /** - * Rate of slow frames during the view’s lifetime (in milliseconds per second) - */ - readonly slow_frames_rate?: number - /** - * Rate of freezes during the view’s lifetime (in seconds per hour) - */ - readonly freeze_rate?: number - /** - * Time taken for Flutter 'build' methods. - */ - flutter_build_time?: RumPerfMetric - /** - * Time taken for Flutter to rasterize the view. - */ - flutter_raster_time?: RumPerfMetric - /** - * The JavaScript refresh rate for React Native - */ - js_refresh_rate?: RumPerfMetric - /** - * Performance data. (Web Vitals, etc.) - */ - performance?: ViewPerformanceData - /** - * Accessibility properties of the view - */ - accessibility?: ViewAccessibilityProperties - [k: string]: unknown - } - /** - * Session properties - */ - readonly session?: { - /** - * Whether this session is currently active. Set to false to manually stop a session - */ - readonly is_active?: boolean - /** - * Whether this session has been sampled for replay - */ - readonly sampled_for_replay?: boolean - [k: string]: unknown - } - /** - * Feature flags properties - */ - readonly feature_flags?: { - [k: string]: unknown - } - /** - * Privacy properties - */ - readonly privacy?: { - /** - * The replay privacy level - */ - readonly replay_level: 'allow' | 'mask' | 'mask-user-input' [k: string]: unknown } /** @@ -1335,32 +1042,27 @@ export type RumViewEvent = CommonProperties & profiling?: ProfilingInternalContextSchema [k: string]: unknown } + [k: string]: unknown + } +/** + * Schema of all properties of a View Update event + */ +export type RumViewUpdateEvent = ViewContainerSchema & + StreamSchema & + ViewProperties & + CommonProperties & { /** - * Display properties + * RUM event type */ - readonly display?: { + readonly type: 'view_update' + /** + * Internal properties + */ + readonly _dd?: { /** - * Scroll properties + * Version of the update of the view event */ - readonly scroll?: { - /** - * Distance between the top and the lowest point reached on this view (in pixels) - */ - readonly max_depth: number - /** - * Page scroll top (scrolled distance) when the maximum scroll depth was reached for this view (in pixels) - */ - readonly max_depth_scroll_top: number - /** - * Maximum page scroll height (total height) for this view (in pixels) - */ - readonly max_scroll_height: number - /** - * Duration between the view start and the time the max scroll height was reached for this view (in nanoseconds) - */ - readonly max_scroll_height_time: number - [k: string]: unknown - } + readonly document_version: number [k: string]: unknown } [k: string]: unknown @@ -1956,6 +1658,348 @@ export interface StreamSchema { } [k: string]: unknown } +/** + * Shared optional view-specific properties used by both view and view_update events + */ +export interface ViewProperties { + /** + * View properties + */ + readonly view?: { + /** + * Duration in ns to the view is considered loaded + */ + readonly loading_time?: number + /** + * Duration in ns from the moment the view was started until all the initial network requests settled + */ + readonly network_settled_time?: number + /** + * Duration in ns to from the last interaction on previous view to the moment the current view was displayed + */ + readonly interaction_to_next_view_time?: number + /** + * Type of the loading of the view + */ + readonly loading_type?: + | 'initial_load' + | 'route_change' + | 'activity_display' + | 'activity_redisplay' + | 'fragment_display' + | 'fragment_redisplay' + | 'view_controller_display' + | 'view_controller_redisplay' + /** + * Time spent on the view in ns + */ + readonly time_spent?: number + /** + * @deprecated + * Duration in ns to the first rendering (deprecated in favor of `view.performance.fcp.timestamp`) + */ + readonly first_contentful_paint?: number + /** + * @deprecated + * Duration in ns to the largest contentful paint (deprecated in favor of `view.performance.lcp.timestamp`) + */ + readonly largest_contentful_paint?: number + /** + * @deprecated + * CSS selector path of the largest contentful paint element (deprecated in favor of `view.performance.lcp.target_selector`) + */ + readonly largest_contentful_paint_target_selector?: string + /** + * @deprecated + * Duration in ns of the first input event delay (deprecated in favor of `view.performance.fid.duration`) + */ + readonly first_input_delay?: number + /** + * @deprecated + * Duration in ns to the first input (deprecated in favor of `view.performance.fid.timestamp`) + */ + readonly first_input_time?: number + /** + * @deprecated + * CSS selector path of the first input target element (deprecated in favor of `view.performance.fid.target_selector`) + */ + readonly first_input_target_selector?: string + /** + * @deprecated + * Longest duration in ns between an interaction and the next paint (deprecated in favor of `view.performance.inp.duration`) + */ + readonly interaction_to_next_paint?: number + /** + * @deprecated + * Duration in ns between start of the view and start of the INP (deprecated in favor of `view.performance.inp.timestamp`) + */ + readonly interaction_to_next_paint_time?: number + /** + * @deprecated + * CSS selector path of the interacted element corresponding to INP (deprecated in favor of `view.performance.inp.target_selector`) + */ + readonly interaction_to_next_paint_target_selector?: string + /** + * @deprecated + * Total layout shift score that occurred on the view (deprecated in favor of `view.performance.cls.score`) + */ + readonly cumulative_layout_shift?: number + /** + * @deprecated + * Duration in ns between start of the view and start of the largest layout shift contributing to CLS (deprecated in favor of `view.performance.cls.timestamp`) + */ + readonly cumulative_layout_shift_time?: number + /** + * @deprecated + * CSS selector path of the first element (in document order) of the largest layout shift contributing to CLS (deprecated in favor of `view.performance.cls.target_selector`) + */ + readonly cumulative_layout_shift_target_selector?: string + /** + * Duration in ns to the complete parsing and loading of the document and its sub resources + */ + readonly dom_complete?: number + /** + * Duration in ns to the complete parsing and loading of the document without its sub resources + */ + readonly dom_content_loaded?: number + /** + * Duration in ns to the end of the parsing of the document + */ + readonly dom_interactive?: number + /** + * Duration in ns to the end of the load event handler execution + */ + readonly load_event?: number + /** + * Duration in ns to the response start of the document request + */ + readonly first_byte?: number + /** + * User custom timings of the view. As timing name is used as facet path, it must contain only letters, digits, or the characters - _ . @ $ + */ + readonly custom_timings?: { + [k: string]: number + } + /** + * Whether the View corresponding to this event is considered active + */ + readonly is_active?: boolean + /** + * Whether the View had a low average refresh rate + */ + readonly is_slow_rendered?: boolean + /** + * Properties of the actions of the view + */ + readonly action?: { + /** + * Number of actions that occurred on the view + */ + readonly count: number + [k: string]: unknown + } + /** + * Properties of the errors of the view + */ + readonly error?: { + /** + * Number of errors that occurred on the view + */ + readonly count: number + [k: string]: unknown + } + /** + * Properties of the crashes of the view + */ + readonly crash?: { + /** + * Number of crashes that occurred on the view + */ + readonly count: number + [k: string]: unknown + } + /** + * Properties of the long tasks of the view + */ + readonly long_task?: { + /** + * Number of long tasks that occurred on the view + */ + readonly count: number + [k: string]: unknown + } + /** + * Properties of the frozen frames of the view + */ + readonly frozen_frame?: { + /** + * Number of frozen frames that occurred on the view + */ + readonly count: number + [k: string]: unknown + } + /** + * List of slow frames during the view's lifetime + */ + readonly slow_frames?: { + /** + * Duration in ns between start of the view and the start of the slow frame + */ + readonly start: number + /** + * Duration in ns of the slow frame + */ + readonly duration: number + [k: string]: unknown + }[] + /** + * Properties of the resources of the view + */ + readonly resource?: { + /** + * Number of resources that occurred on the view + */ + readonly count: number + [k: string]: unknown + } + /** + * Properties of the frustrations of the view + */ + readonly frustration?: { + /** + * Number of frustrations that occurred on the view + */ + readonly count?: number + [k: string]: unknown + } + /** + * List of the periods of time the user had the view in foreground (focused in the browser) + */ + readonly in_foreground_periods?: { + /** + * Duration in ns between start of the view and start of foreground period + */ + readonly start: number + /** + * Duration in ns of the view foreground period + */ + readonly duration: number + [k: string]: unknown + }[] + /** + * Average memory used during the view lifetime (in bytes) + */ + readonly memory_average?: number + /** + * Peak memory used during the view lifetime (in bytes) + */ + readonly memory_max?: number + /** + * Total number of cpu ticks during the view's lifetime + */ + readonly cpu_ticks_count?: number + /** + * Average number of cpu ticks per second during the view's lifetime + */ + readonly cpu_ticks_per_second?: number + /** + * Average refresh rate during the view's lifetime (in frames per second) + */ + readonly refresh_rate_average?: number + /** + * Minimum refresh rate during the view's lifetime (in frames per second) + */ + readonly refresh_rate_min?: number + /** + * Rate of slow frames during the view's lifetime (in milliseconds per second) + */ + readonly slow_frames_rate?: number + /** + * Rate of freezes during the view's lifetime (in seconds per hour) + */ + readonly freeze_rate?: number + /** + * Time taken for Flutter 'build' methods. + */ + flutter_build_time?: RumPerfMetric + /** + * Time taken for Flutter to rasterize the view. + */ + flutter_raster_time?: RumPerfMetric + /** + * The JavaScript refresh rate for React Native + */ + js_refresh_rate?: RumPerfMetric + /** + * Performance data. (Web Vitals, etc.) + */ + performance?: ViewPerformanceData + /** + * Accessibility properties of the view + */ + accessibility?: ViewAccessibilityProperties + [k: string]: unknown + } + /** + * Session properties + */ + readonly session?: { + /** + * Whether this session is currently active. Set to false to manually stop a session + */ + readonly is_active?: boolean + /** + * Whether this session has been sampled for replay + */ + readonly sampled_for_replay?: boolean + [k: string]: unknown + } + /** + * Feature flags properties + */ + readonly feature_flags?: { + [k: string]: unknown + } + /** + * Privacy properties + */ + readonly privacy?: { + /** + * The replay privacy level + */ + readonly replay_level: 'allow' | 'mask' | 'mask-user-input' + [k: string]: unknown + } + /** + * Display properties + */ + readonly display?: { + /** + * Scroll properties + */ + readonly scroll?: { + /** + * Distance between the top and the lowest point reached on this view (in pixels) + */ + readonly max_depth: number + /** + * Page scroll top (scrolled distance) when the maximum scroll depth was reached for this view (in pixels) + */ + readonly max_depth_scroll_top: number + /** + * Maximum page scroll height (total height) for this view (in pixels) + */ + readonly max_scroll_height: number + /** + * Duration between the view start and the time the max scroll height was reached for this view (in nanoseconds) + */ + readonly max_scroll_height_time: number + [k: string]: unknown + } + [k: string]: unknown + } + [k: string]: unknown +} /** * Schema of properties for a technical performance metric */ diff --git a/rum-events-format b/rum-events-format index 5622f2a87d..5ddfd3bbc6 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit 5622f2a87d3e0c1ff3048967df1d71b49c8efa7d +Subproject commit 5ddfd3bbc698ed594461c8a46463917006016388 diff --git a/test/e2e/scenario/rum/views.scenario.ts b/test/e2e/scenario/rum/views.scenario.ts index 7b65010992..794af71a13 100644 --- a/test/e2e/scenario/rum/views.scenario.ts +++ b/test/e2e/scenario/rum/views.scenario.ts @@ -66,6 +66,65 @@ test.describe('rum views', () => { expect(viewEvent).toBeDefined() expect(viewEvent.view.loading_time).toBeGreaterThan(0) }) + + createTest('has a manual loading time') + .withRum() + .withBody(SPINNER) + .run(async ({ flushEvents, intakeRegistry, page }) => { + await page.evaluate( + () => + new Promise((resolve) => { + setTimeout(() => { + window.DD_RUM!.setViewLoadingTime() + resolve() + }, 200) + }) + ) + + await flushEvents() + const viewEvent = intakeRegistry.rumViewEvents.at(-1) + expect(viewEvent).toBeDefined() + expect(viewEvent!.view.loading_time).toBeGreaterThanOrEqual(200 * 1e6) + }) + + createTest('overwrites manual loading time on subsequent calls (last-call-wins)') + .withRum() + .withBody(SPINNER) + .run(async ({ flushEvents, intakeRegistry, page }) => { + await page.evaluate( + () => + new Promise((resolve) => { + setTimeout(() => { + window.DD_RUM!.setViewLoadingTime() + }, 200) + + setTimeout(() => { + window.DD_RUM!.setViewLoadingTime() + resolve() + }, 500) + }) + ) + + await flushEvents() + const viewEvent = intakeRegistry.rumViewEvents.at(-1) + expect(viewEvent).toBeDefined() + // Should reflect the second value (~500ms), not the first (~200ms) + expect(viewEvent!.view.loading_time).toBeGreaterThanOrEqual(500 * 1e6) + }) + + createTest('reports manual loading time when called before init') + .withRum() + .withRumInit((configuration) => { + window.DD_RUM!.setViewLoadingTime() + window.DD_RUM!.init(configuration) + }) + .withBody(SPINNER) + .run(async ({ flushEvents, intakeRegistry }) => { + await flushEvents() + const viewEvent = intakeRegistry.rumViewEvents.at(-1) + expect(viewEvent).toBeDefined() + expect(viewEvent!.view.loading_time).toBeGreaterThanOrEqual(0) + }) }) createTest('send performance first input delay')