From d65e956a12716dfa9bfe89b3532bc73b2202f0c7 Mon Sep 17 00:00:00 2001 From: Aymeric Date: Tue, 29 Aug 2023 10:26:57 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20[RUM-262]=20Move=20view=20metric?= =?UTF-8?q?s=20in=20dedicated=20files=20(#2386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/setupViewTest.specHelper.ts | 36 +- .../view/trackInitialViewTimings.spec.ts | 336 --------- .../view/trackInitialViewTimings.ts | 261 ------- .../view/trackViewEventCounts.spec.ts | 269 ++++++-- .../view/trackViewMetrics.spec.ts | 636 ------------------ .../view/trackViewMetrics.ts | 309 --------- .../view/trackViews.spec.ts | 101 ++- .../rumEventsCollection/view/trackViews.ts | 73 +- .../view/viewCollection.spec.ts | 26 +- .../view/viewCollection.ts | 34 +- .../interactionCountPolyfill.ts | 2 +- .../viewMetrics/trackCommonViewMetrics.ts | 98 +++ .../trackCumulativeLayoutShift.spec.ts | 153 +++++ .../viewMetrics/trackCumulativeLayoutShift.ts | 96 +++ .../trackFirstContentfulPaint.spec.ts | 64 ++ .../viewMetrics/trackFirstContentfulPaint.ts | 33 + .../trackFirstHidden.spec.ts | 2 +- .../{ => viewMetrics}/trackFirstHidden.ts | 2 +- .../trackFirstInputTimings.spec.ts | 76 +++ .../viewMetrics/trackFirstInputTimings.ts | 54 ++ .../trackInitialViewMetrics.spec.ts | 85 +++ .../viewMetrics/trackInitialViewMetrics.ts | 92 +++ .../trackInteractionToNextPaint.spec.ts | 12 +- .../trackInteractionToNextPaint.ts | 11 +- .../trackLargestContentfulPaint.spec.ts | 78 +++ .../trackLargestContentfulPaint.ts | 63 ++ .../view/viewMetrics/trackLoadingTime.spec.ts | 161 +++++ .../view/viewMetrics/trackLoadingTime.ts | 46 ++ .../trackNavigationTimings.spec.ts | 37 + .../viewMetrics/trackNavigationTimings.ts | 34 + .../viewMetrics/trackScrollMetrics.spec.ts | 142 ++++ .../view/viewMetrics/trackScrollMetrics.ts | 69 ++ 32 files changed, 1760 insertions(+), 1731 deletions(-) delete mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/trackInitialViewTimings.spec.ts delete mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/trackInitialViewTimings.ts delete mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/trackViewMetrics.spec.ts delete mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/trackViewMetrics.ts rename packages/rum-core/src/domain/rumEventsCollection/view/{ => viewMetrics}/interactionCountPolyfill.ts (97%) create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackCommonViewMetrics.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackCumulativeLayoutShift.spec.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackCumulativeLayoutShift.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstContentfulPaint.spec.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstContentfulPaint.ts rename packages/rum-core/src/domain/rumEventsCollection/view/{ => viewMetrics}/trackFirstHidden.spec.ts (98%) rename packages/rum-core/src/domain/rumEventsCollection/view/{ => viewMetrics}/trackFirstHidden.ts (95%) create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstInputTimings.spec.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstInputTimings.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInitialViewMetrics.spec.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInitialViewMetrics.ts rename packages/rum-core/src/domain/rumEventsCollection/view/{ => viewMetrics}/trackInteractionToNextPaint.spec.ts (94%) rename packages/rum-core/src/domain/rumEventsCollection/view/{ => viewMetrics}/trackInteractionToNextPaint.ts (90%) create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLargestContentfulPaint.spec.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLargestContentfulPaint.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLoadingTime.spec.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLoadingTime.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackNavigationTimings.spec.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackNavigationTimings.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackScrollMetrics.spec.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackScrollMetrics.ts diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/setupViewTest.specHelper.ts b/packages/rum-core/src/domain/rumEventsCollection/view/setupViewTest.specHelper.ts index 4b68a876e8..49412bc971 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/setupViewTest.specHelper.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/setupViewTest.specHelper.ts @@ -1,6 +1,13 @@ +import type { Duration, RelativeTime } from '@datadog/browser-core' import { noopWebVitalTelemetryDebug } from '../../../../test' -import { type BuildContext } from '../../../../test' +import type { BuildContext } from '../../../../test' import { LifeCycleEventType } from '../../lifeCycle' +import type { + RumFirstInputTiming, + RumLargestContentfulPaintTiming, + RumPerformanceNavigationTiming, + RumPerformancePaintTiming, +} from '../../../browser/performanceCollection' import type { ViewEvent, ViewOptions } from './trackViews' import { trackViews } from './trackViews' @@ -66,3 +73,30 @@ function spyOnViews(name?: string) { return { handler, getViewEvent, getHandledCount } } + +export const FAKE_PAINT_ENTRY: RumPerformancePaintTiming = { + entryType: 'paint', + name: 'first-contentful-paint', + startTime: 123 as RelativeTime, +} +export const FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY: RumLargestContentfulPaintTiming = { + entryType: 'largest-contentful-paint', + startTime: 789 as RelativeTime, + size: 10, +} + +export const FAKE_NAVIGATION_ENTRY: RumPerformanceNavigationTiming = { + responseStart: 123 as RelativeTime, + domComplete: 456 as RelativeTime, + domContentLoadedEventEnd: 345 as RelativeTime, + domInteractive: 234 as RelativeTime, + entryType: 'navigation', + loadEventEnd: 567 as RelativeTime, +} + +export const FAKE_FIRST_INPUT_ENTRY: RumFirstInputTiming = { + entryType: 'first-input', + processingStart: 1100 as RelativeTime, + startTime: 1000 as RelativeTime, + duration: 0 as Duration, +} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackInitialViewTimings.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackInitialViewTimings.spec.ts deleted file mode 100644 index f38d4d57f8..0000000000 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackInitialViewTimings.spec.ts +++ /dev/null @@ -1,336 +0,0 @@ -import type { Duration, RelativeTime } from '@datadog/browser-core' -import { DOM_EVENT } from '@datadog/browser-core' -import { restorePageVisibility, setPageVisibility, createNewEvent } from '@datadog/browser-core/test' -import type { TestSetupBuilder } from '../../../../test' -import { noopWebVitalTelemetryDebug, setup } from '../../../../test' -import type { - RumFirstInputTiming, - RumLargestContentfulPaintTiming, - RumPerformanceNavigationTiming, - RumPerformancePaintTiming, -} from '../../../browser/performanceCollection' -import { LifeCycleEventType } from '../../lifeCycle' -import type { RumConfiguration } from '../../configuration' -import { resetFirstHidden } from './trackFirstHidden' -import type { Timings } from './trackInitialViewTimings' -import { - KEEP_TRACKING_TIMINGS_AFTER_VIEW_DELAY, - trackFirstContentfulPaintTiming, - trackFirstInputTimings, - trackLargestContentfulPaintTiming, - trackNavigationTimings, - trackInitialViewTimings, - TIMING_MAXIMUM_DELAY, -} from './trackInitialViewTimings' - -const FAKE_PAINT_ENTRY: RumPerformancePaintTiming = { - entryType: 'paint', - name: 'first-contentful-paint', - startTime: 123 as RelativeTime, -} - -const FAKE_NAVIGATION_ENTRY: RumPerformanceNavigationTiming = { - responseStart: 123 as RelativeTime, - domComplete: 456 as RelativeTime, - domContentLoadedEventEnd: 345 as RelativeTime, - domInteractive: 234 as RelativeTime, - entryType: 'navigation', - loadEventEnd: 567 as RelativeTime, -} - -const FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY: RumLargestContentfulPaintTiming = { - entryType: 'largest-contentful-paint', - startTime: 789 as RelativeTime, - size: 10, - element: document.createElement('div'), -} - -const FAKE_FIRST_INPUT_ENTRY: RumFirstInputTiming = { - entryType: 'first-input', - processingStart: 1100 as RelativeTime, - startTime: 1000 as RelativeTime, - target: document.createElement('button'), - duration: 0 as Duration, -} - -describe('trackInitialViewTimings', () => { - let setupBuilder: TestSetupBuilder - let scheduleViewUpdateSpy: jasmine.Spy<() => void> - let trackInitialViewTimingsResult: ReturnType - let setLoadEventSpy: jasmine.Spy<(loadEvent: Duration) => void> - let configuration: RumConfiguration - - beforeEach(() => { - configuration = {} as RumConfiguration - scheduleViewUpdateSpy = jasmine.createSpy() - setLoadEventSpy = jasmine.createSpy() - - setupBuilder = setup().beforeBuild(({ lifeCycle }) => { - trackInitialViewTimingsResult = trackInitialViewTimings( - lifeCycle, - configuration, - noopWebVitalTelemetryDebug, - setLoadEventSpy, - scheduleViewUpdateSpy - ) - return trackInitialViewTimingsResult - }) - }) - - afterEach(() => { - setupBuilder.cleanup() - }) - - it('should merge timings from various sources', () => { - const { lifeCycle } = setupBuilder.build() - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ - FAKE_NAVIGATION_ENTRY, - FAKE_PAINT_ENTRY, - FAKE_FIRST_INPUT_ENTRY, - ]) - - expect(scheduleViewUpdateSpy).toHaveBeenCalledTimes(3) - expect(trackInitialViewTimingsResult.timings).toEqual({ - firstByte: 123 as Duration, - domComplete: 456 as Duration, - domContentLoaded: 345 as Duration, - domInteractive: 234 as Duration, - firstContentfulPaint: 123 as Duration, - firstInputDelay: 100 as Duration, - firstInputTime: 1000 as Duration, - loadEvent: 567 as Duration, - }) - }) - - it('allows delaying the stop logic', () => { - const { lifeCycle, clock } = setupBuilder.withFakeClock().build() - trackInitialViewTimingsResult.scheduleStop() - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_NAVIGATION_ENTRY]) - - expect(scheduleViewUpdateSpy).toHaveBeenCalledTimes(1) - - clock.tick(KEEP_TRACKING_TIMINGS_AFTER_VIEW_DELAY) - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_PAINT_ENTRY]) - - expect(scheduleViewUpdateSpy).toHaveBeenCalledTimes(1) - }) - - it('calls the `setLoadEvent` callback when the loadEvent timing is known', () => { - const { lifeCycle } = setupBuilder.build() - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ - FAKE_NAVIGATION_ENTRY, - FAKE_PAINT_ENTRY, - FAKE_FIRST_INPUT_ENTRY, - ]) - - expect(setLoadEventSpy).toHaveBeenCalledOnceWith(567 as Duration) - }) -}) - -describe('trackNavigationTimings', () => { - let setupBuilder: TestSetupBuilder - let navigationTimingsCallback: jasmine.Spy<(value: Partial) => void> - - beforeEach(() => { - navigationTimingsCallback = jasmine.createSpy() - setupBuilder = setup().beforeBuild(({ lifeCycle }) => trackNavigationTimings(lifeCycle, navigationTimingsCallback)) - }) - - afterEach(() => { - setupBuilder.cleanup() - }) - - it('should provide the first contentful paint timing', () => { - const { lifeCycle } = setupBuilder.build() - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_NAVIGATION_ENTRY]) - - expect(navigationTimingsCallback).toHaveBeenCalledTimes(1) - expect(navigationTimingsCallback).toHaveBeenCalledWith({ - firstByte: 123 as Duration, - domComplete: 456 as Duration, - domContentLoaded: 345 as Duration, - domInteractive: 234 as Duration, - loadEvent: 567 as Duration, - }) - }) -}) - -describe('trackFirstContentfulPaintTiming', () => { - let setupBuilder: TestSetupBuilder - let fcpCallback: jasmine.Spy<(value: RelativeTime) => void> - let configuration: RumConfiguration - - beforeEach(() => { - configuration = {} as RumConfiguration - fcpCallback = jasmine.createSpy() - setupBuilder = setup().beforeBuild(({ lifeCycle }) => - trackFirstContentfulPaintTiming(lifeCycle, configuration, fcpCallback) - ) - resetFirstHidden() - }) - - afterEach(() => { - setupBuilder.cleanup() - restorePageVisibility() - resetFirstHidden() - }) - - it('should provide the first contentful paint timing', () => { - const { lifeCycle } = setupBuilder.build() - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_PAINT_ENTRY]) - - expect(fcpCallback).toHaveBeenCalledTimes(1 as RelativeTime) - expect(fcpCallback).toHaveBeenCalledWith(123 as RelativeTime) - }) - - it('should be discarded if the page is hidden', () => { - setPageVisibility('hidden') - const { lifeCycle } = setupBuilder.build() - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_PAINT_ENTRY]) - expect(fcpCallback).not.toHaveBeenCalled() - }) - - it('should be discarded if it is reported after a long time', () => { - const { lifeCycle } = setupBuilder.build() - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ - { - ...FAKE_PAINT_ENTRY, - startTime: TIMING_MAXIMUM_DELAY as RelativeTime, - }, - ]) - expect(fcpCallback).not.toHaveBeenCalled() - }) -}) - -describe('largestContentfulPaintTiming', () => { - let setupBuilder: TestSetupBuilder - let lcpCallback: jasmine.Spy<(value: RelativeTime, lcpElement: Element | undefined) => void> - let eventTarget: Window - let configuration: RumConfiguration - - beforeEach(() => { - configuration = {} as RumConfiguration - lcpCallback = jasmine.createSpy() - eventTarget = document.createElement('div') as unknown as Window - setupBuilder = setup().beforeBuild(({ lifeCycle }) => - trackLargestContentfulPaintTiming(lifeCycle, configuration, eventTarget, lcpCallback) - ) - resetFirstHidden() - }) - - afterEach(() => { - setupBuilder.cleanup() - restorePageVisibility() - resetFirstHidden() - }) - - it('should provide the largest contentful paint timing', () => { - const { lifeCycle } = setupBuilder.build() - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY]) - expect(lcpCallback).toHaveBeenCalledTimes(1 as RelativeTime) - expect(lcpCallback).toHaveBeenCalledWith(789 as RelativeTime, jasmine.any(Element)) - }) - - it('should be discarded if it is reported after a user interaction', () => { - const { lifeCycle } = setupBuilder.build() - - eventTarget.dispatchEvent(createNewEvent(DOM_EVENT.KEY_DOWN, { timeStamp: 1 })) - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY]) - expect(lcpCallback).not.toHaveBeenCalled() - }) - - it('should be discarded if the page is hidden', () => { - setPageVisibility('hidden') - const { lifeCycle } = setupBuilder.build() - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY]) - - expect(lcpCallback).not.toHaveBeenCalled() - }) - - it('should be discarded if it is reported after a long time', () => { - const { lifeCycle } = setupBuilder.build() - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ - { - ...FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY, - startTime: TIMING_MAXIMUM_DELAY as RelativeTime, - }, - ]) - expect(lcpCallback).not.toHaveBeenCalled() - }) -}) - -describe('firstInputTimings', () => { - let setupBuilder: TestSetupBuilder - let fitCallback: jasmine.Spy< - ({ - firstInputDelay, - firstInputTime, - }: { - firstInputDelay: number - firstInputTime: number - firstInputTarget: Node | undefined - }) => void - > - let configuration: RumConfiguration - - beforeEach(() => { - configuration = {} as RumConfiguration - fitCallback = jasmine.createSpy() - setupBuilder = setup().beforeBuild(({ lifeCycle }) => trackFirstInputTimings(lifeCycle, configuration, fitCallback)) - resetFirstHidden() - }) - - afterEach(() => { - setupBuilder.cleanup() - restorePageVisibility() - resetFirstHidden() - }) - - it('should provide the first input timings', () => { - const { lifeCycle } = setupBuilder.build() - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_FIRST_INPUT_ENTRY]) - expect(fitCallback).toHaveBeenCalledTimes(1) - expect(fitCallback).toHaveBeenCalledWith({ - firstInputDelay: 100, - firstInputTime: 1000, - firstInputTarget: jasmine.any(Node), - }) - }) - - it('should be discarded if the page is hidden', () => { - setPageVisibility('hidden') - const { lifeCycle } = setupBuilder.build() - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_FIRST_INPUT_ENTRY]) - - expect(fitCallback).not.toHaveBeenCalled() - }) - - it('should be adjusted to 0 if the computed value would be negative due to browser timings imprecisions', () => { - const { lifeCycle } = setupBuilder.build() - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ - { - entryType: 'first-input' as const, - processingStart: 900 as RelativeTime, - startTime: 1000 as RelativeTime, - duration: 0 as Duration, - }, - ]) - - expect(fitCallback).toHaveBeenCalledTimes(1) - expect(fitCallback).toHaveBeenCalledWith({ firstInputDelay: 0, firstInputTime: 1000, firstInputTarget: undefined }) - }) -}) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackInitialViewTimings.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackInitialViewTimings.ts deleted file mode 100644 index 7b2f48b669..0000000000 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackInitialViewTimings.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type { Duration, RelativeTime } from '@datadog/browser-core' -import { - setTimeout, - assign, - addEventListeners, - DOM_EVENT, - elapsed, - ONE_MINUTE, - find, - findLast, - relativeNow, -} from '@datadog/browser-core' - -import type { RumConfiguration } from '../../configuration' -import type { LifeCycle } from '../../lifeCycle' -import { LifeCycleEventType } from '../../lifeCycle' -import type { - RumFirstInputTiming, - RumLargestContentfulPaintTiming, - RumPerformancePaintTiming, -} from '../../../browser/performanceCollection' -import { trackFirstHidden } from './trackFirstHidden' -import type { WebVitalTelemetryDebug } from './startWebVitalTelemetryDebug' - -// Discard LCP and FCP timings above a certain delay to avoid incorrect data -// It happens in some cases like sleep mode or some browser implementations -export const TIMING_MAXIMUM_DELAY = 10 * ONE_MINUTE - -/** - * The initial view can finish quickly, before some metrics can be produced (ex: before the page load - * event, or the first input). Also, we don't want to trigger a view update indefinitely, to avoid - * updates on views that ended a long time ago. Keep watching for metrics after the view ends for a - * limited amount of time. - */ -export const KEEP_TRACKING_TIMINGS_AFTER_VIEW_DELAY = 5 * ONE_MINUTE - -export interface Timings { - firstContentfulPaint?: Duration - firstByte?: Duration - domInteractive?: Duration - domContentLoaded?: Duration - domComplete?: Duration - loadEvent?: Duration - largestContentfulPaint?: Duration - firstInputDelay?: Duration - firstInputTime?: Duration -} - -export function trackInitialViewTimings( - lifeCycle: LifeCycle, - configuration: RumConfiguration, - webVitalTelemetryDebug: WebVitalTelemetryDebug, - setLoadEvent: (loadEnd: Duration) => void, - scheduleViewUpdate: () => void -) { - const timings: Timings = {} - - function setTimings(newTimings: Partial) { - assign(timings, newTimings) - scheduleViewUpdate() - } - - const { stop: stopNavigationTracking } = trackNavigationTimings(lifeCycle, (newTimings) => { - setLoadEvent(newTimings.loadEvent) - setTimings(newTimings) - }) - const { stop: stopFCPTracking } = trackFirstContentfulPaintTiming(lifeCycle, configuration, (firstContentfulPaint) => - setTimings({ firstContentfulPaint }) - ) - const { stop: stopLCPTracking } = trackLargestContentfulPaintTiming( - lifeCycle, - configuration, - window, - (largestContentfulPaint, lcpElement) => { - webVitalTelemetryDebug.addWebVitalTelemetryDebug('LCP', lcpElement, largestContentfulPaint) - - setTimings({ - largestContentfulPaint, - }) - } - ) - - const { stop: stopFIDTracking } = trackFirstInputTimings( - lifeCycle, - configuration, - ({ firstInputDelay, firstInputTime, firstInputTarget }) => { - webVitalTelemetryDebug.addWebVitalTelemetryDebug('FID', firstInputTarget, firstInputTime) - - setTimings({ - firstInputDelay, - firstInputTime, - }) - } - ) - - function stop() { - stopNavigationTracking() - stopFCPTracking() - stopLCPTracking() - stopFIDTracking() - } - - return { - stop, - timings, - scheduleStop: () => { - setTimeout(stop, KEEP_TRACKING_TIMINGS_AFTER_VIEW_DELAY) - }, - } -} - -interface NavigationTimings { - domComplete: Duration - domContentLoaded: Duration - domInteractive: Duration - loadEvent: Duration - firstByte: Duration | undefined -} - -export function trackNavigationTimings(lifeCycle: LifeCycle, callback: (timings: NavigationTimings) => void) { - const { unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, (entries) => { - for (const entry of entries) { - if (entry.entryType === 'navigation') { - callback({ - domComplete: entry.domComplete, - domContentLoaded: entry.domContentLoadedEventEnd, - domInteractive: entry.domInteractive, - loadEvent: entry.loadEventEnd, - // In some cases the value reported is negative or is larger - // than the current page time. Ignore these cases: - // https://github.com/GoogleChrome/web-vitals/issues/137 - // https://github.com/GoogleChrome/web-vitals/issues/162 - firstByte: entry.responseStart >= 0 && entry.responseStart <= relativeNow() ? entry.responseStart : undefined, - }) - } - } - }) - - return { stop } -} - -export function trackFirstContentfulPaintTiming( - lifeCycle: LifeCycle, - configuration: RumConfiguration, - callback: (fcpTiming: RelativeTime) => void -) { - const firstHidden = trackFirstHidden(configuration) - const { unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, (entries) => { - const fcpEntry = find( - entries, - (entry): entry is RumPerformancePaintTiming => - entry.entryType === 'paint' && - entry.name === 'first-contentful-paint' && - entry.startTime < firstHidden.timeStamp && - entry.startTime < TIMING_MAXIMUM_DELAY - ) - if (fcpEntry) { - callback(fcpEntry.startTime) - } - }) - return { stop } -} - -/** - * Track the largest contentful paint (LCP) occurring during the initial View. This can yield - * multiple values, only the most recent one should be used. - * Documentation: https://web.dev/lcp/ - * Reference implementation: https://github.com/GoogleChrome/web-vitals/blob/master/src/getLCP.ts - */ -export function trackLargestContentfulPaintTiming( - lifeCycle: LifeCycle, - configuration: RumConfiguration, - eventTarget: Window, - callback: (lcpTiming: RelativeTime, lcpElement?: Element) => void -) { - const firstHidden = trackFirstHidden(configuration) - - // Ignore entries that come after the first user interaction. According to the documentation, the - // browser should not send largest-contentful-paint entries after a user interact with the page, - // but the web-vitals reference implementation uses this as a safeguard. - let firstInteractionTimestamp = Infinity - const { stop: stopEventListener } = addEventListeners( - configuration, - eventTarget, - [DOM_EVENT.POINTER_DOWN, DOM_EVENT.KEY_DOWN], - (event) => { - firstInteractionTimestamp = event.timeStamp - }, - { capture: true, once: true } - ) - - const { unsubscribe: unsubscribeLifeCycle } = lifeCycle.subscribe( - LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, - (entries) => { - const lcpEntry = findLast( - entries, - (entry): entry is RumLargestContentfulPaintTiming => - entry.entryType === 'largest-contentful-paint' && - entry.startTime < firstInteractionTimestamp && - entry.startTime < firstHidden.timeStamp && - entry.startTime < TIMING_MAXIMUM_DELAY - ) - if (lcpEntry) { - callback(lcpEntry.startTime, lcpEntry.element) - } - } - ) - - return { - stop: () => { - stopEventListener() - unsubscribeLifeCycle() - }, - } -} - -/** - * Track the first input occurring during the initial View to return: - * - First Input Delay - * - First Input Time - * Callback is called at most one time. - * Documentation: https://web.dev/fid/ - * Reference implementation: https://github.com/GoogleChrome/web-vitals/blob/master/src/getFID.ts - */ -export function trackFirstInputTimings( - lifeCycle: LifeCycle, - configuration: RumConfiguration, - callback: ({ - firstInputDelay, - firstInputTime, - firstInputTarget, - }: { - firstInputDelay: Duration - firstInputTime: RelativeTime - firstInputTarget: Node | undefined - }) => void -) { - const firstHidden = trackFirstHidden(configuration) - - const { unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, (entries) => { - const firstInputEntry = find( - entries, - (entry): entry is RumFirstInputTiming => - entry.entryType === 'first-input' && entry.startTime < firstHidden.timeStamp - ) - if (firstInputEntry) { - const firstInputDelay = elapsed(firstInputEntry.startTime, firstInputEntry.processingStart) - callback({ - // Ensure firstInputDelay to be positive, see - // https://bugs.chromium.org/p/chromium/issues/detail?id=1185815 - firstInputDelay: firstInputDelay >= 0 ? firstInputDelay : (0 as Duration), - firstInputTime: firstInputEntry.startTime, - firstInputTarget: firstInputEntry.target, - }) - } - }) - - return { - stop, - } -} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackViewEventCounts.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackViewEventCounts.spec.ts index d12de22d51..a200acbe4c 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackViewEventCounts.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/trackViewEventCounts.spec.ts @@ -1,77 +1,256 @@ import type { Context } from '@datadog/browser-core' -import { noop } from '@datadog/browser-core' -import type { Clock } from '@datadog/browser-core/test' -import { mockClock } from '@datadog/browser-core/test' -import type { RumResourceEvent } from '../../../rumEvent.types' -import { RumEventType } from '../../../rawRumEvent.types' -import { LifeCycle, LifeCycleEventType } from '../../lifeCycle' -import { KEEP_TRACKING_EVENT_COUNTS_AFTER_VIEW_DELAY, trackViewEventCounts } from './trackViewEventCounts' +import type { RumEvent } from '../../../rumEvent.types' +import { LifeCycleEventType } from '../../lifeCycle' +import type { TestSetupBuilder } from '../../../../test' +import { setup } from '../../../../test' +import { FrustrationType, RumEventType } from '../../../rawRumEvent.types' +import { THROTTLE_VIEW_UPDATE_PERIOD } from './trackViews' +import type { ViewTest } from './setupViewTest.specHelper' +import { setupViewTest } from './setupViewTest.specHelper' +import { KEEP_TRACKING_EVENT_COUNTS_AFTER_VIEW_DELAY } from './trackViewEventCounts' describe('trackViewEventCounts', () => { - const VIEW_ID = 'a' - const OTHER_VIEW_ID = 'b' - let lifeCycle: LifeCycle - let clock: Clock | undefined + let setupBuilder: TestSetupBuilder + let viewTest: ViewTest beforeEach(() => { - lifeCycle = new LifeCycle() + setupBuilder = setup() + .withFakeLocation('/foo') + .beforeBuild((buildContext) => { + viewTest = setupViewTest(buildContext) + return viewTest + }) }) afterEach(() => { - if (clock) { - clock.cleanup() - } + setupBuilder.cleanup() }) - it('initializes eventCounts to 0', () => { - const { eventCounts } = trackViewEventCounts(lifeCycle, VIEW_ID, noop) + it('should track error count', () => { + const { lifeCycle } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest - expect(eventCounts).toEqual({ - actionCount: 0, - errorCount: 0, - longTaskCount: 0, - frustrationCount: 0, - resourceCount: 0, - }) + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).eventCounts.errorCount).toEqual(0) + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + view: getLatestViewContext(), + } as RumEvent & Context) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + view: getLatestViewContext(), + } as RumEvent & Context) + startView() + + expect(getViewUpdateCount()).toEqual(3) + expect(getViewUpdate(1).eventCounts.errorCount).toEqual(2) + expect(getViewUpdate(2).eventCounts.errorCount).toEqual(0) + }) + + it('should track long task count', () => { + const { lifeCycle } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).eventCounts.longTaskCount).toEqual(0) + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.LONG_TASK, + view: getLatestViewContext(), + } as RumEvent & Context) + startView() + + expect(getViewUpdateCount()).toEqual(3) + expect(getViewUpdate(1).eventCounts.longTaskCount).toEqual(1) + expect(getViewUpdate(2).eventCounts.longTaskCount).toEqual(0) + }) + + it('should track resource count', () => { + const { lifeCycle } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).eventCounts.resourceCount).toEqual(0) + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.RESOURCE, + view: getLatestViewContext(), + } as RumEvent & Context) + startView() + + expect(getViewUpdateCount()).toEqual(3) + expect(getViewUpdate(1).eventCounts.resourceCount).toEqual(1) + expect(getViewUpdate(2).eventCounts.resourceCount).toEqual(0) + }) + + it('should track action count', () => { + const { lifeCycle } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).eventCounts.actionCount).toEqual(0) + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ACTION, + action: { type: 'custom' }, + view: getLatestViewContext(), + } as RumEvent & Context) + startView() + + expect(getViewUpdateCount()).toEqual(3) + expect(getViewUpdate(1).eventCounts.actionCount).toEqual(1) + expect(getViewUpdate(2).eventCounts.actionCount).toEqual(0) + }) + + it('should track frustration count', () => { + const { lifeCycle } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).eventCounts.frustrationCount).toEqual(0) + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ACTION, + action: { + type: 'click', + id: '123', + frustration: { + type: [FrustrationType.DEAD_CLICK, FrustrationType.ERROR_CLICK], + }, + }, + view: getLatestViewContext(), + } as RumEvent & Context) + startView() + + expect(getViewUpdateCount()).toEqual(3) + expect(getViewUpdate(1).eventCounts.frustrationCount).toEqual(2) + expect(getViewUpdate(2).eventCounts.frustrationCount).toEqual(0) + }) + + it('should not count child events unrelated to the view', () => { + const { lifeCycle } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount, startView } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).eventCounts.errorCount).toEqual(0) + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.ERROR, + view: { id: 'unrelated-view-id' }, + } as RumEvent & Context) + startView() + + expect(getViewUpdateCount()).toEqual(3) + expect(getViewUpdate(1).eventCounts.errorCount).toEqual(0) + expect(getViewUpdate(2).eventCounts.errorCount).toEqual(0) }) - it('increments counters', () => { - const { eventCounts } = trackViewEventCounts(lifeCycle, VIEW_ID, noop) + it('should reset event count when the view changes', () => { + const { lifeCycle, changeLocation } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).eventCounts.resourceCount).toEqual(0) + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.RESOURCE, + view: getLatestViewContext(), + } as RumEvent & Context) + startView() - notifyResourceEvent() + expect(getViewUpdateCount()).toEqual(3) + expect(getViewUpdate(1).eventCounts.resourceCount).toEqual(1) + expect(getViewUpdate(2).eventCounts.resourceCount).toEqual(0) - expect(eventCounts.resourceCount).toBe(1) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.RESOURCE, + view: getLatestViewContext(), + } as RumEvent & Context) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.RESOURCE, + view: getLatestViewContext(), + } as RumEvent & Context) + changeLocation('/baz') + + expect(getViewUpdateCount()).toEqual(5) + expect(getViewUpdate(3).eventCounts.resourceCount).toEqual(2) + expect(getViewUpdate(4).eventCounts.resourceCount).toEqual(0) }) - it('does not increment counters related to other views', () => { - const { eventCounts } = trackViewEventCounts(lifeCycle, VIEW_ID, noop) + it('should update eventCounts when a resource event is collected (throttled)', () => { + const { lifeCycle, clock } = setupBuilder.withFakeClock().build() + const { getViewUpdate, getViewUpdateCount, getLatestViewContext } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).eventCounts).toEqual({ + errorCount: 0, + longTaskCount: 0, + resourceCount: 0, + actionCount: 0, + frustrationCount: 0, + }) + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.RESOURCE, + view: getLatestViewContext(), + } as RumEvent & Context) - notifyResourceEvent(OTHER_VIEW_ID) + expect(getViewUpdateCount()).toEqual(1) - expect(eventCounts.resourceCount).toBe(0) + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdateCount()).toEqual(2) + expect(getViewUpdate(1).eventCounts).toEqual({ + errorCount: 0, + longTaskCount: 0, + resourceCount: 1, + actionCount: 0, + frustrationCount: 0, + }) }) - it('when calling scheduleStop, it keeps counting events for a bit of time', () => { - clock = mockClock() - const { scheduleStop, eventCounts } = trackViewEventCounts(lifeCycle, VIEW_ID, noop) + it('should keep updating the view event counters for 5 min after view end', () => { + const { lifeCycle, clock } = setupBuilder.withFakeClock().build() + const { getViewUpdate, getViewUpdateCount, getLatestViewContext, stop } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).eventCounts.resourceCount).toEqual(0) - scheduleStop() + stop() // end the view clock.tick(KEEP_TRACKING_EVENT_COUNTS_AFTER_VIEW_DELAY - 1) - notifyResourceEvent() - expect(eventCounts.resourceCount).toBe(1) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: RumEventType.RESOURCE, + view: getLatestViewContext(), + } as RumEvent & Context) - clock.tick(1) - notifyResourceEvent() + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - expect(eventCounts.resourceCount).toBe(1) + expect(getViewUpdate(0).id).toEqual(getViewUpdate(1).id) + expect(getViewUpdate(1).eventCounts.resourceCount).toEqual(1) }) - function notifyResourceEvent(viewId = VIEW_ID) { + it('should not keep updating the view event counters 5 min after view end', () => { + const { lifeCycle, clock } = setupBuilder.withFakeClock().build() + const { getViewUpdate, getViewUpdateCount, getLatestViewContext, stop } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).eventCounts.resourceCount).toEqual(0) + + stop() // end the view + + clock.tick(KEEP_TRACKING_EVENT_COUNTS_AFTER_VIEW_DELAY) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { type: RumEventType.RESOURCE, - view: { id: viewId }, - } as unknown as RumResourceEvent & Context) - } + view: getLatestViewContext(), + } as RumEvent & Context) + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdate(0).id).toEqual(getViewUpdate(1).id) + expect(getViewUpdate(1).eventCounts.resourceCount).toEqual(0) + }) }) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackViewMetrics.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackViewMetrics.spec.ts deleted file mode 100644 index 3284c24ea7..0000000000 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackViewMetrics.spec.ts +++ /dev/null @@ -1,636 +0,0 @@ -import type { Context, RelativeTime, TimeStamp, Duration } from '@datadog/browser-core' -import { DOM_EVENT, addDuration, relativeNow } from '@datadog/browser-core' -import type { Clock } from '@datadog/browser-core/test' -import { createNewEvent, mockClock } from '@datadog/browser-core/test' -import type { RumEvent } from '../../../rumEvent.types' -import type { TestSetupBuilder } from '../../../../test' -import { setup } from '../../../../test' -import type { RumPerformanceNavigationTiming } from '../../../browser/performanceCollection' -import { FrustrationType, RumEventType } from '../../../rawRumEvent.types' -import type { LifeCycle } from '../../lifeCycle' -import { LifeCycleEventType } from '../../lifeCycle' -import { PAGE_ACTIVITY_END_DELAY, PAGE_ACTIVITY_VALIDATION_DELAY } from '../../waitPageActivityEnd' -import type { RumConfiguration } from '../../configuration' -import { THROTTLE_VIEW_UPDATE_PERIOD } from './trackViews' -import type { ViewTest } from './setupViewTest.specHelper' -import { setupViewTest } from './setupViewTest.specHelper' -import type { ScrollMetrics } from './trackViewMetrics' -import { THROTTLE_SCROLL_DURATION, trackScrollMetrics } from './trackViewMetrics' - -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 - -const FAKE_NAVIGATION_ENTRY: RumPerformanceNavigationTiming = { - responseStart: 123 as RelativeTime, - domComplete: 456 as RelativeTime, - domContentLoadedEventEnd: 345 as RelativeTime, - domInteractive: 234 as RelativeTime, - entryType: 'navigation', - loadEventEnd: 567 as RelativeTime, -} - -const FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_BEFORE_ACTIVITY_TIMING: RumPerformanceNavigationTiming = { - responseStart: 1 as RelativeTime, - domComplete: 2 as RelativeTime, - domContentLoadedEventEnd: 1 as RelativeTime, - domInteractive: 1 as RelativeTime, - entryType: 'navigation', - loadEventEnd: (BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY * 0.8) as RelativeTime, -} - -const FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_AFTER_ACTIVITY_TIMING: RumPerformanceNavigationTiming = { - responseStart: 1 as RelativeTime, - domComplete: 2 as RelativeTime, - domContentLoadedEventEnd: 1 as RelativeTime, - domInteractive: 1 as RelativeTime, - entryType: 'navigation', - loadEventEnd: (BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY * 1.2) as RelativeTime, -} - -describe('rum track view metrics', () => { - let setupBuilder: TestSetupBuilder - let viewTest: ViewTest - - beforeEach(() => { - setupBuilder = setup() - .withFakeLocation('/foo') - .beforeBuild((buildContext) => { - viewTest = setupViewTest(buildContext) - return viewTest - }) - }) - - afterEach(() => { - setupBuilder.cleanup() - }) - - describe('loading time', () => { - beforeEach(() => { - setupBuilder.withFakeClock() - }) - - it('should have an undefined loading time if there is no activity on a route change', () => { - const { clock } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount, startView } = viewTest - - startView() - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(2).loadingTime).toBeUndefined() - }) - - it('should have a loading time equal to the activity time if there is a unique activity on a route change', () => { - const { domMutationObservable, clock } = setupBuilder.build() - const { getViewUpdate, startView } = viewTest - - startView() - clock.tick(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) - domMutationObservable.notify() - clock.tick(AFTER_PAGE_ACTIVITY_END_DELAY) - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdate(3).loadingTime).toEqual(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) - }) - - it('should use loadEventEnd for initial view when having no activity', () => { - const { lifeCycle, clock } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_NAVIGATION_ENTRY]) - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdateCount()).toEqual(2) - expect(getViewUpdate(1).loadingTime).toEqual(FAKE_NAVIGATION_ENTRY.loadEventEnd) - }) - - it('should use loadEventEnd for initial view when load event is bigger than computed loading time', () => { - const { lifeCycle, domMutationObservable, clock } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - - clock.tick(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ - FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_AFTER_ACTIVITY_TIMING, - ]) - - domMutationObservable.notify() - clock.tick(AFTER_PAGE_ACTIVITY_END_DELAY) - - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdateCount()).toEqual(2) - expect(getViewUpdate(1).loadingTime).toEqual( - FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_AFTER_ACTIVITY_TIMING.loadEventEnd - ) - }) - - it('should use computed loading time for initial view when load event is smaller than computed loading time', () => { - const { lifeCycle, domMutationObservable, clock } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - - clock.tick(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ - FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_BEFORE_ACTIVITY_TIMING, - ]) - domMutationObservable.notify() - clock.tick(AFTER_PAGE_ACTIVITY_END_DELAY) - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdateCount()).toEqual(2) - expect(getViewUpdate(1).loadingTime).toEqual(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) - }) - - it('should use computed loading time from time origin for initial view', () => { - // introduce a gap between time origin and tracking start - // ensure that `load event > activity delay` and `load event < activity delay + clock gap` - // to make the test fail if the clock gap is not correctly taken into account - const CLOCK_GAP = (FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_AFTER_ACTIVITY_TIMING.loadEventEnd - - BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY + - 1) as Duration - - setupBuilder.clock!.tick(CLOCK_GAP) - - const { lifeCycle, domMutationObservable, clock } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - - clock.tick(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) - - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ - FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_AFTER_ACTIVITY_TIMING, - ]) - - domMutationObservable.notify() - clock.tick(AFTER_PAGE_ACTIVITY_END_DELAY) - - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdateCount()).toEqual(2) - expect(getViewUpdate(1).loadingTime).toEqual(addDuration(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY, CLOCK_GAP)) - }) - }) - - describe('event counts', () => { - it('should track error count', () => { - const { lifeCycle } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).eventCounts.errorCount).toEqual(0) - - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.ERROR, - view: getLatestViewContext(), - } as RumEvent & Context) - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.ERROR, - view: getLatestViewContext(), - } as RumEvent & Context) - startView() - - expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(1).eventCounts.errorCount).toEqual(2) - expect(getViewUpdate(2).eventCounts.errorCount).toEqual(0) - }) - - it('should track long task count', () => { - const { lifeCycle } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).eventCounts.longTaskCount).toEqual(0) - - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.LONG_TASK, - view: getLatestViewContext(), - } as RumEvent & Context) - startView() - - expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(1).eventCounts.longTaskCount).toEqual(1) - expect(getViewUpdate(2).eventCounts.longTaskCount).toEqual(0) - }) - - it('should track resource count', () => { - const { lifeCycle } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).eventCounts.resourceCount).toEqual(0) - - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.RESOURCE, - view: getLatestViewContext(), - } as RumEvent & Context) - startView() - - expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(1).eventCounts.resourceCount).toEqual(1) - expect(getViewUpdate(2).eventCounts.resourceCount).toEqual(0) - }) - - it('should track action count', () => { - const { lifeCycle } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).eventCounts.actionCount).toEqual(0) - - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.ACTION, - action: { type: 'custom' }, - view: getLatestViewContext(), - } as RumEvent & Context) - startView() - - expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(1).eventCounts.actionCount).toEqual(1) - expect(getViewUpdate(2).eventCounts.actionCount).toEqual(0) - }) - - it('should track frustration count', () => { - const { lifeCycle } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).eventCounts.frustrationCount).toEqual(0) - - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.ACTION, - action: { - type: 'click', - id: '123', - frustration: { - type: [FrustrationType.DEAD_CLICK, FrustrationType.ERROR_CLICK], - }, - }, - view: getLatestViewContext(), - } as RumEvent & Context) - startView() - - expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(1).eventCounts.frustrationCount).toEqual(2) - expect(getViewUpdate(2).eventCounts.frustrationCount).toEqual(0) - }) - - it('should not count child events unrelated to the view', () => { - const { lifeCycle } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount, startView } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).eventCounts.errorCount).toEqual(0) - - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.ERROR, - view: { id: 'unrelated-view-id' }, - } as RumEvent & Context) - startView() - - expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(1).eventCounts.errorCount).toEqual(0) - expect(getViewUpdate(2).eventCounts.errorCount).toEqual(0) - }) - - it('should reset event count when the view changes', () => { - const { lifeCycle, changeLocation } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).eventCounts.resourceCount).toEqual(0) - - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.RESOURCE, - view: getLatestViewContext(), - } as RumEvent & Context) - startView() - - expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(1).eventCounts.resourceCount).toEqual(1) - expect(getViewUpdate(2).eventCounts.resourceCount).toEqual(0) - - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.RESOURCE, - view: getLatestViewContext(), - } as RumEvent & Context) - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.RESOURCE, - view: getLatestViewContext(), - } as RumEvent & Context) - changeLocation('/baz') - - expect(getViewUpdateCount()).toEqual(5) - expect(getViewUpdate(3).eventCounts.resourceCount).toEqual(2) - expect(getViewUpdate(4).eventCounts.resourceCount).toEqual(0) - }) - - it('should update eventCounts when a resource event is collected (throttled)', () => { - const { lifeCycle, clock } = setupBuilder.withFakeClock().build() - const { getViewUpdate, getViewUpdateCount, getLatestViewContext } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).eventCounts).toEqual({ - errorCount: 0, - longTaskCount: 0, - resourceCount: 0, - actionCount: 0, - frustrationCount: 0, - }) - - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.RESOURCE, - view: getLatestViewContext(), - } as RumEvent & Context) - - expect(getViewUpdateCount()).toEqual(1) - - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdateCount()).toEqual(2) - expect(getViewUpdate(1).eventCounts).toEqual({ - errorCount: 0, - longTaskCount: 0, - resourceCount: 1, - actionCount: 0, - frustrationCount: 0, - }) - }) - - it('should not update eventCounts after ending a view', () => { - const { lifeCycle, clock } = setupBuilder.withFakeClock().build() - const { getViewUpdate, getViewUpdateCount, startView, getLatestViewContext } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { - type: RumEventType.RESOURCE, - view: getLatestViewContext(), - } as RumEvent & Context) - - expect(getViewUpdateCount()).toEqual(1) - - startView() - - expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(1).id).toEqual(getViewUpdate(0).id) - expect(getViewUpdate(2).id).not.toEqual(getViewUpdate(0).id) - - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdateCount()).toEqual(3) - }) - }) - - describe('cumulativeLayoutShift', () => { - let isLayoutShiftSupported: boolean - let originalSupportedEntryTypes: PropertyDescriptor | undefined - - function newLayoutShift(lifeCycle: LifeCycle, { value = 0.1, hadRecentInput = false }) { - lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ - { - entryType: 'layout-shift', - startTime: relativeNow(), - hadRecentInput, - value, - }, - ]) - } - - beforeEach(() => { - if (!('PerformanceObserver' in window) || !('supportedEntryTypes' in PerformanceObserver)) { - pending('No PerformanceObserver support') - } - originalSupportedEntryTypes = Object.getOwnPropertyDescriptor(PerformanceObserver, 'supportedEntryTypes') - isLayoutShiftSupported = true - Object.defineProperty(PerformanceObserver, 'supportedEntryTypes', { - get: () => (isLayoutShiftSupported ? ['layout-shift'] : []), - }) - }) - - afterEach(() => { - if (originalSupportedEntryTypes) { - Object.defineProperty(PerformanceObserver, 'supportedEntryTypes', originalSupportedEntryTypes) - } - }) - - it('should be initialized to 0', () => { - setupBuilder.build() - const { getViewUpdate, getViewUpdateCount } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).cumulativeLayoutShift).toBe(0) - }) - - it('should be initialized to undefined if layout-shift is not supported', () => { - isLayoutShiftSupported = false - setupBuilder.build() - const { getViewUpdate, getViewUpdateCount } = viewTest - - expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).cumulativeLayoutShift).toBe(undefined) - }) - - it('should accumulate layout shift values for the first session window', () => { - const { lifeCycle, clock } = setupBuilder.withFakeClock().build() - const { getViewUpdate, getViewUpdateCount } = viewTest - newLayoutShift(lifeCycle, { value: 0.1 }) - clock.tick(100) - newLayoutShift(lifeCycle, { value: 0.2 }) - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdateCount()).toEqual(2) - expect(getViewUpdate(1).cumulativeLayoutShift).toBe(0.3) - }) - - it('should round the cumulative layout shift value to 4 decimals', () => { - const { lifeCycle, clock } = setupBuilder.withFakeClock().build() - const { getViewUpdate, getViewUpdateCount } = viewTest - newLayoutShift(lifeCycle, { value: 1.23456789 }) - clock.tick(100) - newLayoutShift(lifeCycle, { value: 1.11111111111 }) - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdateCount()).toEqual(2) - expect(getViewUpdate(1).cumulativeLayoutShift).toBe(2.3457) - }) - - it('should ignore entries with recent input', () => { - const { lifeCycle, clock } = setupBuilder.withFakeClock().build() - const { getViewUpdate, getViewUpdateCount } = viewTest - - newLayoutShift(lifeCycle, { value: 0.1, hadRecentInput: true }) - - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).cumulativeLayoutShift).toBe(0) - }) - - it('should create a new session window if the gap is more than 1 second', () => { - const { lifeCycle, clock } = setupBuilder.withFakeClock().build() - const { getViewUpdate, getViewUpdateCount } = viewTest - // first session window - newLayoutShift(lifeCycle, { value: 0.1 }) - clock.tick(100) - newLayoutShift(lifeCycle, { value: 0.2 }) - // second session window - clock.tick(1001) - newLayoutShift(lifeCycle, { value: 0.1 }) - - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - expect(getViewUpdateCount()).toEqual(2) - expect(getViewUpdate(1).cumulativeLayoutShift).toBe(0.3) - }) - - it('should create a new session window if the current session window is more than 5 second', () => { - const { lifeCycle, clock } = setupBuilder.withFakeClock().build() - const { getViewUpdate, getViewUpdateCount } = viewTest - newLayoutShift(lifeCycle, { value: 0 }) - for (let i = 0; i < 6; i += 1) { - clock.tick(999) - newLayoutShift(lifeCycle, { value: 0.1 }) - } // window 1: 0.5 | window 2: 0.1 - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(2).cumulativeLayoutShift).toBe(0.5) - }) - - it('should get the max value sessions', () => { - const { lifeCycle, clock } = setupBuilder.withFakeClock().build() - const { getViewUpdate, getViewUpdateCount } = viewTest - // first session window - newLayoutShift(lifeCycle, { value: 0.1 }) - newLayoutShift(lifeCycle, { value: 0.2 }) - // second session window - clock.tick(5001) - newLayoutShift(lifeCycle, { value: 0.1 }) - newLayoutShift(lifeCycle, { value: 0.2 }) - newLayoutShift(lifeCycle, { value: 0.2 }) - // third session window - clock.tick(5001) - newLayoutShift(lifeCycle, { value: 0.2 }) - newLayoutShift(lifeCycle, { value: 0.2 }) - - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(2).cumulativeLayoutShift).toBe(0.5) - }) - }) - - describe('scroll metrics', () => { - describe('on scroll', () => { - let scrollMetrics: ScrollMetrics | undefined - let stopTrackScrollMetrics: () => void - let clock: Clock - let configuration: RumConfiguration - - const getMetrics = jasmine.createSpy('getMetrics') - - const newScroll = (scrollParams: { scrollHeight: number; scrollDepth: number; scrollTop: number }) => { - getMetrics.and.returnValue(scrollParams) - - window.dispatchEvent(createNewEvent(DOM_EVENT.SCROLL)) - - clock.tick(THROTTLE_SCROLL_DURATION) - } - - beforeEach(() => { - configuration = {} as RumConfiguration - clock = mockClock() - stopTrackScrollMetrics = trackScrollMetrics( - configuration, - { relative: 0 as RelativeTime, timeStamp: 0 as TimeStamp }, - (s) => (scrollMetrics = s), - getMetrics - ).stop - }) - - afterEach(() => { - stopTrackScrollMetrics() - scrollMetrics = undefined - clock.cleanup() - }) - - it('should update scroll metrics when scrolling the first time', () => { - newScroll({ scrollHeight: 1000, scrollDepth: 500, scrollTop: 100 }) - - expect(scrollMetrics).toEqual({ - maxDepthScrollHeight: 1000, - maxDepth: 500, - maxDepthScrollTop: 100, - maxDepthTime: 1000 as Duration, - }) - }) - - it('should update scroll metrics when scroll depth has increased', () => { - newScroll({ scrollHeight: 1000, scrollDepth: 500, scrollTop: 100 }) - - newScroll({ scrollHeight: 1000, scrollDepth: 600, scrollTop: 200 }) - - expect(scrollMetrics).toEqual({ - maxDepthScrollHeight: 1000, - maxDepth: 600, - maxDepthScrollTop: 200, - maxDepthTime: 2000 as Duration, - }) - }) - - it('should NOT update scroll metrics when scroll depth has not increased', () => { - newScroll({ scrollHeight: 1000, scrollDepth: 600, scrollTop: 200 }) - - newScroll({ scrollHeight: 1000, scrollDepth: 450, scrollTop: 50 }) - - expect(scrollMetrics).toEqual({ - maxDepthScrollHeight: 1000, - maxDepth: 600, - maxDepthScrollTop: 200, - maxDepthTime: 1000 as Duration, - }) - }) - }) - - describe('on load', () => { - beforeEach(() => { - setupBuilder.withFakeClock() - }) - it('should have an undefined loading time and empty scroll metrics if there is no activity on a route change', () => { - const { clock } = setupBuilder.build() - const { getViewUpdate, getViewUpdateCount, startView } = viewTest - - startView() - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(2).loadingTime).toBeUndefined() - expect(getViewUpdate(2).scrollMetrics).toEqual(undefined) - }) - - it('should have a loading time equal to the activity time and scroll metrics if there is a unique activity on a route change', () => { - const { domMutationObservable, clock } = setupBuilder.build() - const { getViewUpdate, startView } = viewTest - - startView() - clock.tick(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) - domMutationObservable.notify() - clock.tick(AFTER_PAGE_ACTIVITY_END_DELAY) - clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) - - expect(getViewUpdate(3).loadingTime).toEqual(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) - expect(getViewUpdate(3).scrollMetrics).toEqual({ - maxDepthScrollHeight: jasmine.any(Number), - maxDepth: jasmine.any(Number), - maxDepthTime: jasmine.any(Number), - maxDepthScrollTop: jasmine.any(Number), - }) - }) - }) - }) -}) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackViewMetrics.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackViewMetrics.ts deleted file mode 100644 index 91b86efed9..0000000000 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackViewMetrics.ts +++ /dev/null @@ -1,309 +0,0 @@ -import type { ClocksState, Duration, Observable, RelativeTime } from '@datadog/browser-core' -import { - DOM_EVENT, - ONE_SECOND, - addEventListener, - elapsed, - noop, - relativeNow, - round, - throttle, - find, -} from '@datadog/browser-core' -import type { RumLayoutShiftTiming } from '../../../browser/performanceCollection' -import { supportPerformanceTimingEvent } from '../../../browser/performanceCollection' -import { ViewLoadingType } from '../../../rawRumEvent.types' -import type { RumConfiguration } from '../../configuration' -import type { LifeCycle } from '../../lifeCycle' -import { LifeCycleEventType } from '../../lifeCycle' -import { waitPageActivityEnd } from '../../waitPageActivityEnd' - -import { getScrollY } from '../../../browser/scroll' -import { getViewportDimension } from '../../../browser/viewportObservable' -import type { WebVitalTelemetryDebug } from './startWebVitalTelemetryDebug' -import { trackInteractionToNextPaint } from './trackInteractionToNextPaint' - -export interface ScrollMetrics { - maxDepth: number - maxDepthScrollHeight: number - maxDepthScrollTop: number - maxDepthTime: Duration -} - -/** Arbitrary scroll throttle duration */ -export const THROTTLE_SCROLL_DURATION = ONE_SECOND - -export interface ViewMetrics { - loadingTime?: Duration - cumulativeLayoutShift?: number - interactionToNextPaint?: Duration -} - -export function trackViewMetrics( - lifeCycle: LifeCycle, - domMutationObservable: Observable, - configuration: RumConfiguration, - scheduleViewUpdate: () => void, - loadingType: ViewLoadingType, - viewStart: ClocksState, - webVitalTelemetryDebug: WebVitalTelemetryDebug -) { - const viewMetrics: ViewMetrics = {} - - let scrollMetrics: ScrollMetrics | undefined - - const { stop: stopLoadingTimeTracking, setLoadEvent } = trackLoadingTime( - lifeCycle, - domMutationObservable, - configuration, - loadingType, - viewStart, - (newLoadingTime) => { - viewMetrics.loadingTime = newLoadingTime - - // We compute scroll metrics at loading time to ensure we have scroll data when loading the view initially - // This is to ensure that we have the depth data even if the user didn't scroll or if the view is not scrollable. - const { scrollHeight, scrollDepth, scrollTop } = computeScrollValues() - - scrollMetrics = { - maxDepth: scrollDepth, - maxDepthScrollHeight: scrollHeight, - maxDepthTime: newLoadingTime, - maxDepthScrollTop: scrollTop, - } - scheduleViewUpdate() - } - ) - - const { stop: stopScrollMetricsTracking } = trackScrollMetrics( - configuration, - viewStart, - (newScrollMetrics) => { - scrollMetrics = newScrollMetrics - }, - computeScrollValues - ) - - let stopCLSTracking: () => void - let clsAttributionCollected = false - if (isLayoutShiftSupported()) { - viewMetrics.cumulativeLayoutShift = 0 - ;({ stop: stopCLSTracking } = trackCumulativeLayoutShift( - lifeCycle, - (cumulativeLayoutShift, largestLayoutShiftNode, largestLayoutShiftTime) => { - viewMetrics.cumulativeLayoutShift = cumulativeLayoutShift - - if (!clsAttributionCollected) { - clsAttributionCollected = true - webVitalTelemetryDebug.addWebVitalTelemetryDebug('CLS', largestLayoutShiftNode, largestLayoutShiftTime) - } - scheduleViewUpdate() - } - )) - } else { - stopCLSTracking = noop - } - - const { stop: stopINPTracking, getInteractionToNextPaint } = trackInteractionToNextPaint(loadingType, lifeCycle) - - return { - stop: () => { - stopLoadingTimeTracking() - stopCLSTracking() - stopScrollMetricsTracking() - stopINPTracking() - }, - setLoadEvent, - getViewMetrics: () => { - viewMetrics.interactionToNextPaint = getInteractionToNextPaint() - return viewMetrics - }, - getScrollMetrics: () => scrollMetrics, - } -} - -export function trackScrollMetrics( - configuration: RumConfiguration, - viewStart: ClocksState, - callback: (scrollMetrics: ScrollMetrics) => void, - getScrollValues = computeScrollValues -) { - let maxDepth = 0 - const handleScrollEvent = throttle( - () => { - const { scrollHeight, scrollDepth, scrollTop } = getScrollValues() - - if (scrollDepth > maxDepth) { - const now = relativeNow() - const maxDepthTime = elapsed(viewStart.relative, now) - maxDepth = scrollDepth - callback({ - maxDepth, - maxDepthScrollHeight: scrollHeight, - maxDepthTime, - maxDepthScrollTop: scrollTop, - }) - } - }, - THROTTLE_SCROLL_DURATION, - { leading: false, trailing: true } - ) - - const { stop } = addEventListener(configuration, window, DOM_EVENT.SCROLL, handleScrollEvent.throttled, { - passive: true, - }) - - return { - stop: () => { - handleScrollEvent.cancel() - stop() - }, - } -} - -function computeScrollValues() { - const scrollTop = getScrollY() - - const { height } = getViewportDimension() - - const scrollHeight = Math.round((document.scrollingElement || document.documentElement).scrollHeight) - const scrollDepth = Math.round(height + scrollTop) - - return { - scrollHeight, - scrollDepth, - scrollTop, - } -} - -function trackLoadingTime( - lifeCycle: LifeCycle, - domMutationObservable: Observable, - configuration: RumConfiguration, - loadType: ViewLoadingType, - viewStart: ClocksState, - callback: (loadingTime: Duration) => void -) { - let isWaitingForLoadEvent = loadType === ViewLoadingType.INITIAL_LOAD - let isWaitingForActivityLoadingTime = true - const loadingTimeCandidates: Duration[] = [] - - function invokeCallbackIfAllCandidatesAreReceived() { - if (!isWaitingForActivityLoadingTime && !isWaitingForLoadEvent && loadingTimeCandidates.length > 0) { - callback(Math.max(...loadingTimeCandidates) as Duration) - } - } - - const { stop } = waitPageActivityEnd(lifeCycle, domMutationObservable, configuration, (event) => { - if (isWaitingForActivityLoadingTime) { - isWaitingForActivityLoadingTime = false - if (event.hadActivity) { - loadingTimeCandidates.push(elapsed(viewStart.timeStamp, event.end)) - } - invokeCallbackIfAllCandidatesAreReceived() - } - }) - - return { - stop, - setLoadEvent: (loadEvent: Duration) => { - if (isWaitingForLoadEvent) { - isWaitingForLoadEvent = false - loadingTimeCandidates.push(loadEvent) - invokeCallbackIfAllCandidatesAreReceived() - } - }, - } -} - -/** - * Track the cumulative layout shifts (CLS). - * Layout shifts are grouped into session windows. - * The minimum gap between session windows is 1 second. - * The maximum duration of a session window is 5 second. - * The session window layout shift value is the sum of layout shifts inside it. - * The CLS value is the max of session windows values. - * - * This yields a new value whenever the CLS value is updated (a higher session window value is computed). - * - * See isLayoutShiftSupported to check for browser support. - * - * Documentation: - * https://web.dev/cls/ - * https://web.dev/evolving-cls/ - * Reference implementation: https://github.com/GoogleChrome/web-vitals/blob/master/src/getCLS.ts - */ -function trackCumulativeLayoutShift( - lifeCycle: LifeCycle, - callback: (layoutShift: number, largestShiftNode: Node | undefined, largestShiftTime: RelativeTime) => void -) { - let maxClsValue = 0 - - const window = slidingSessionWindow() - const { unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, (entries) => { - for (const entry of entries) { - if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) { - window.update(entry) - - if (window.value() > maxClsValue) { - maxClsValue = window.value() - callback(round(maxClsValue, 4), window.largestLayoutShiftNode(), window.largestLayoutShiftTime()) - } - } - } - }) - - return { - stop, - } -} - -function slidingSessionWindow() { - let value = 0 - let startTime: RelativeTime - let endTime: RelativeTime - - let largestLayoutShift = 0 - let largestLayoutShiftNode: Node | undefined - let largestLayoutShiftTime: RelativeTime - - return { - update: (entry: RumLayoutShiftTiming) => { - const shouldCreateNewWindow = - startTime === undefined || - entry.startTime - endTime >= ONE_SECOND || - entry.startTime - startTime >= 5 * ONE_SECOND - if (shouldCreateNewWindow) { - startTime = endTime = entry.startTime - value = entry.value - largestLayoutShift = 0 - largestLayoutShiftNode = undefined - } else { - value += entry.value - endTime = entry.startTime - } - - if (entry.value > largestLayoutShift) { - largestLayoutShift = entry.value - largestLayoutShiftTime = entry.startTime - - if (entry.sources?.length) { - const largestLayoutShiftSource = find(entry.sources, (s) => s.node?.nodeType === 1) || entry.sources[0] - largestLayoutShiftNode = largestLayoutShiftSource.node - } else { - largestLayoutShiftNode = undefined - } - } - }, - value: () => value, - largestLayoutShiftNode: () => largestLayoutShiftNode, - largestLayoutShiftTime: () => largestLayoutShiftTime, - } -} - -/** - * Check whether `layout-shift` is supported by the browser. - */ -function isLayoutShiftSupported() { - return supportPerformanceTimingEvent('layout-shift') -} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.spec.ts index 5531b7f1fd..af7229d4a2 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.spec.ts @@ -9,38 +9,19 @@ import { } from '@datadog/browser-core' import type { TestSetupBuilder } from '../../../../test' import { setup } from '../../../../test' -import type { - RumLargestContentfulPaintTiming, - RumPerformanceNavigationTiming, - RumPerformancePaintTiming, -} from '../../../browser/performanceCollection' import { RumEventType, ViewLoadingType } from '../../../rawRumEvent.types' import type { RumEvent } from '../../../rumEvent.types' import { LifeCycleEventType } from '../../lifeCycle' import type { ViewEvent } from './trackViews' import { SESSION_KEEP_ALIVE_INTERVAL, THROTTLE_VIEW_UPDATE_PERIOD } from './trackViews' import type { ViewTest } from './setupViewTest.specHelper' -import { setupViewTest } from './setupViewTest.specHelper' -import { KEEP_TRACKING_TIMINGS_AFTER_VIEW_DELAY } from './trackInitialViewTimings' - -const FAKE_PAINT_ENTRY: RumPerformancePaintTiming = { - entryType: 'paint', - name: 'first-contentful-paint', - startTime: 123 as RelativeTime, -} -const FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY: RumLargestContentfulPaintTiming = { - entryType: 'largest-contentful-paint', - startTime: 789 as RelativeTime, - size: 10, -} -const FAKE_NAVIGATION_ENTRY: RumPerformanceNavigationTiming = { - responseStart: 123 as RelativeTime, - domComplete: 456 as RelativeTime, - domContentLoadedEventEnd: 345 as RelativeTime, - domInteractive: 234 as RelativeTime, - entryType: 'navigation', - loadEventEnd: 567 as RelativeTime, -} +import { + FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY, + FAKE_NAVIGATION_ENTRY, + FAKE_PAINT_ENTRY, + setupViewTest, +} from './setupViewTest.specHelper' +import { KEEP_TRACKING_METRICS_AFTER_VIEW_DELAY } from './viewMetrics/trackInitialViewMetrics' describe('track views automatically', () => { let setupBuilder: TestSetupBuilder @@ -137,13 +118,13 @@ describe('initial view', () => { expect(getViewCreate(0).name).toBe('initial view name') }) - describe('timings', () => { - it('should update timings when notified with a PERFORMANCE_ENTRY_COLLECTED event (throttled)', () => { + describe('metrics', () => { + it('should update initial view metrics when notified with a PERFORMANCE_ENTRY_COLLECTED event (throttled)', () => { const { lifeCycle, clock } = setupBuilder.withFakeClock().build() const { getViewUpdateCount, getViewUpdate } = viewTest expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).timings).toEqual({}) + expect(getViewUpdate(0).initialViewMetrics).toEqual({}) clock.tick(FAKE_NAVIGATION_ENTRY.responseStart) // ensure now > responseStart lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_NAVIGATION_ENTRY]) @@ -153,7 +134,7 @@ describe('initial view', () => { clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) expect(getViewUpdateCount()).toEqual(2) - expect(getViewUpdate(1).timings).toEqual({ + expect(getViewUpdate(1).initialViewMetrics).toEqual({ firstByte: 123 as Duration, domComplete: 456 as Duration, domContentLoaded: 345 as Duration, @@ -162,12 +143,12 @@ describe('initial view', () => { }) }) - it('should update timings when ending a view', () => { + it('should update initial view metrics when ending a view', () => { const { lifeCycle } = setupBuilder.build() const { getViewUpdateCount, getViewUpdate, startView } = viewTest expect(getViewUpdateCount()).toEqual(1) - expect(getViewUpdate(0).timings).toEqual({}) + expect(getViewUpdate(0).initialViewMetrics).toEqual({}) lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ FAKE_PAINT_ENTRY, @@ -179,16 +160,18 @@ describe('initial view', () => { startView() expect(getViewUpdateCount()).toEqual(3) - expect(getViewUpdate(1).timings).toEqual({ - firstByte: 123 as Duration, - domComplete: 456 as Duration, - domContentLoaded: 345 as Duration, - domInteractive: 234 as Duration, - firstContentfulPaint: 123 as Duration, - largestContentfulPaint: 789 as Duration, - loadEvent: 567 as Duration, - }) - expect(getViewUpdate(2).timings).toEqual({}) + expect(getViewUpdate(1).initialViewMetrics).toEqual( + jasmine.objectContaining({ + firstByte: 123 as Duration, + domComplete: 456 as Duration, + domContentLoaded: 345 as Duration, + domInteractive: 234 as Duration, + firstContentfulPaint: 123 as Duration, + largestContentfulPaint: 789 as Duration, + loadEvent: 567 as Duration, + }) + ) + expect(getViewUpdate(2).initialViewMetrics).toEqual({}) }) describe('load event happening after initial view end', () => { @@ -231,20 +214,22 @@ describe('initial view', () => { } }) - it('should not set timings to the second view', () => { - expect(secondView.last.timings).toEqual({}) + it('should not set metrics to the second view', () => { + expect(secondView.last.initialViewMetrics).toEqual({}) }) - it('should set timings only on the initial view', () => { - expect(initialView.last.timings).toEqual({ - firstByte: 123 as Duration, - domComplete: 456 as Duration, - domContentLoaded: 345 as Duration, - domInteractive: 234 as Duration, - firstContentfulPaint: 123 as Duration, - largestContentfulPaint: 789 as Duration, - loadEvent: 567 as Duration, - }) + it('should set initial view metrics only on the initial view', () => { + expect(initialView.last.initialViewMetrics).toEqual( + jasmine.objectContaining({ + firstByte: 123 as Duration, + domComplete: 456 as Duration, + domContentLoaded: 345 as Duration, + domInteractive: 234 as Duration, + firstContentfulPaint: 123 as Duration, + largestContentfulPaint: 789 as Duration, + loadEvent: 567 as Duration, + }) + ) }) it('should not update the initial view duration when updating it with new timings', () => { @@ -253,17 +238,17 @@ describe('initial view', () => { }) it('should update the initial view loadingTime following the loadEventEnd value', () => { - expect(initialView.last.loadingTime).toBe(FAKE_NAVIGATION_ENTRY.loadEventEnd) + expect(initialView.last.commonViewMetrics.loadingTime).toBe(FAKE_NAVIGATION_ENTRY.loadEventEnd) }) }) - it('should not update timings long after the view ended', () => { + it('should not update initial view metrics long after the view ended', () => { const { lifeCycle, clock } = setupBuilder.withFakeClock().build() const { getViewUpdateCount, startView } = viewTest startView() - clock.tick(KEEP_TRACKING_TIMINGS_AFTER_VIEW_DELAY) + clock.tick(KEEP_TRACKING_METRICS_AFTER_VIEW_DELAY) expect(getViewUpdateCount()).toEqual(4) @@ -804,7 +789,7 @@ describe('start view', () => { }) }) -describe('view metrics', () => { +describe('view event count', () => { let setupBuilder: TestSetupBuilder let viewTest: ViewTest diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts index 5ad439e68a..f2ed7ddff3 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts @@ -3,7 +3,6 @@ import { noop, PageExitReason, shallowClone, - assign, elapsed, generateUUID, ONE_MINUTE, @@ -25,12 +24,12 @@ import { LifeCycleEventType } from '../../lifeCycle' import type { EventCounts } from '../../trackEventCounts' import type { LocationChange } from '../../../browser/locationChangeObservable' import type { RumConfiguration } from '../../configuration' -import type { Timings } from './trackInitialViewTimings' -import { trackInitialViewTimings } from './trackInitialViewTimings' -import type { ScrollMetrics } from './trackViewMetrics' -import { trackViewMetrics } from './trackViewMetrics' import { trackViewEventCounts } from './trackViewEventCounts' import type { WebVitalTelemetryDebug } from './startWebVitalTelemetryDebug' +import { trackInitialViewMetrics } from './viewMetrics/trackInitialViewMetrics' +import type { InitialViewMetrics } from './viewMetrics/trackInitialViewMetrics' +import { trackCommonViewMetrics } from './viewMetrics/trackCommonViewMetrics' +import type { CommonViewMetrics } from './viewMetrics/trackCommonViewMetrics' export interface ViewEvent { id: string @@ -38,7 +37,8 @@ export interface ViewEvent { service?: string version?: string location: Readonly - timings: Timings + commonViewMetrics: CommonViewMetrics + initialViewMetrics: InitialViewMetrics customTimings: ViewCustomTimings eventCounts: EventCounts documentVersion: number @@ -46,11 +46,7 @@ export interface ViewEvent { duration: Duration isActive: boolean sessionIsActive: boolean - loadingTime?: Duration loadingType: ViewLoadingType - cumulativeLayoutShift?: number - scrollMetrics?: ScrollMetrics - interactionToNextPaint?: Duration } export interface ViewCreatedEvent { @@ -198,10 +194,9 @@ function newView( const { setLoadEvent, - stop: stopViewMetricsTracking, - getViewMetrics, - getScrollMetrics, - } = trackViewMetrics( + stop: stopCommonViewMetricsTracking, + getCommonViewMetrics, + } = trackCommonViewMetrics( lifeCycle, domMutationObservable, configuration, @@ -211,10 +206,10 @@ function newView( webVitalTelemetryDebug ) - const { scheduleStop: scheduleStopInitialViewTimingsTracking, timings } = + const { scheduleStop: scheduleStopInitialViewMetricsTracking, initialViewMetrics } = loadingType === ViewLoadingType.INITIAL_LOAD - ? trackInitialViewTimings(lifeCycle, configuration, webVitalTelemetryDebug, setLoadEvent, scheduleViewUpdate) - : { scheduleStop: noop, timings: {} as Timings } + ? trackInitialViewMetrics(lifeCycle, configuration, webVitalTelemetryDebug, setLoadEvent, scheduleViewUpdate) + : { scheduleStop: noop, initialViewMetrics: {} as InitialViewMetrics } const { scheduleStop: scheduleStopEventCountsTracking, eventCounts } = trackViewEventCounts( lifeCycle, @@ -233,29 +228,23 @@ function newView( documentVersion += 1 const currentEnd = endClocks === undefined ? timeStampNow() : endClocks.timeStamp - lifeCycle.notify( - LifeCycleEventType.VIEW_UPDATED, - assign( - { - customTimings, - documentVersion, - id, - name, - service, - version, - loadingType, - location, - startClocks, - timings, - duration: elapsed(startClocks.timeStamp, currentEnd), - isActive: endClocks === undefined, - sessionIsActive, - eventCounts, - scrollMetrics: getScrollMetrics(), - }, - getViewMetrics() - ) - ) + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + customTimings, + documentVersion, + id, + name, + service, + version, + loadingType, + location, + startClocks, + commonViewMetrics: getCommonViewMetrics(), + initialViewMetrics, + duration: elapsed(startClocks.timeStamp, currentEnd), + isActive: endClocks === undefined, + sessionIsActive, + eventCounts, + }) } return { @@ -272,8 +261,8 @@ function newView( lifeCycle.notify(LifeCycleEventType.VIEW_ENDED, { endClocks }) clearInterval(keepAliveIntervalId) - stopViewMetricsTracking() - scheduleStopInitialViewTimingsTracking() + stopCommonViewMetricsTracking() + scheduleStopInitialViewMetricsTracking() scheduleStopEventCountsTracking() triggerViewUpdate() }, diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.spec.ts index 26b3188898..3220a62f06 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.spec.ts @@ -11,8 +11,6 @@ import type { ViewEvent } from './trackViews' import { startViewCollection } from './viewCollection' const VIEW: ViewEvent = { - cumulativeLayoutShift: 1, - interactionToNextPaint: 10 as Duration, customTimings: { bar: 20 as Duration, foo: 10 as Duration, @@ -29,11 +27,10 @@ const VIEW: ViewEvent = { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', name: undefined, isActive: false, - loadingTime: 20 as Duration, loadingType: ViewLoadingType.INITIAL_LOAD, location: {} as Location, startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp }, - timings: { + initialViewMetrics: { firstByte: 10 as Duration, domComplete: 10 as Duration, domContentLoaded: 10 as Duration, @@ -44,11 +41,16 @@ const VIEW: ViewEvent = { largestContentfulPaint: 10 as Duration, loadEvent: 10 as Duration, }, - scrollMetrics: { - maxDepth: 2000, - maxDepthScrollHeight: 3000, - maxDepthTime: 4000000000 as Duration, - maxDepthScrollTop: 1000, + commonViewMetrics: { + loadingTime: 20 as Duration, + cumulativeLayoutShift: 1, + interactionToNextPaint: 10 as Duration, + scroll: { + maxDepth: 2000, + maxDepthScrollHeight: 3000, + maxDepthTime: 4000000000 as Duration, + maxDepthScrollTop: 1000, + }, }, sessionIsActive: true, } @@ -194,7 +196,7 @@ describe('viewCollection', () => { .withFeatureFlagContexts({ findFeatureFlagEvaluations: () => ({ feature: 'foo' }) }) .build() - const view = { ...VIEW, loadingTime: -20 as Duration } + const view: ViewEvent = { ...VIEW, commonViewMetrics: { loadingTime: -20 as Duration } } lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, view) const rawRumViewEvent = rawRumEvents[rawRumEvents.length - 1].rawRumEvent as RawRumViewEvent @@ -203,7 +205,7 @@ describe('viewCollection', () => { it('should discard negative loading time', () => { const { lifeCycle, rawRumEvents } = setupBuilder.build() - const view = { ...VIEW, loadingTime: -20 as Duration } + const view: ViewEvent = { ...VIEW, commonViewMetrics: { loadingTime: -20 as Duration } } lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, view) const rawRumViewEvent = rawRumEvents[rawRumEvents.length - 1].rawRumEvent as RawRumViewEvent @@ -231,7 +233,7 @@ describe('viewCollection', () => { it('should not include scroll metrics when there are not scroll metrics in the raw event', () => { const { lifeCycle, rawRumEvents } = setupBuilder.build() - lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...VIEW, scrollMetrics: undefined }) + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...VIEW, commonViewMetrics: { scroll: undefined } }) const rawRumViewEvent = rawRumEvents[rawRumEvents.length - 1].rawRumEvent as RawRumViewEvent expect(rawRumViewEvent.display?.scroll).toBeUndefined() diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.ts index ede40b2458..24c29bae51 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.ts @@ -79,23 +79,23 @@ function processViewUpdate( frustration: { count: view.eventCounts.frustrationCount, }, - cumulative_layout_shift: view.cumulativeLayoutShift, - first_byte: toServerDuration(view.timings.firstByte), - dom_complete: toServerDuration(view.timings.domComplete), - dom_content_loaded: toServerDuration(view.timings.domContentLoaded), - dom_interactive: toServerDuration(view.timings.domInteractive), + cumulative_layout_shift: view.commonViewMetrics.cumulativeLayoutShift, + first_byte: toServerDuration(view.initialViewMetrics.firstByte), + dom_complete: toServerDuration(view.initialViewMetrics.domComplete), + dom_content_loaded: toServerDuration(view.initialViewMetrics.domContentLoaded), + dom_interactive: toServerDuration(view.initialViewMetrics.domInteractive), error: { count: view.eventCounts.errorCount, }, - first_contentful_paint: toServerDuration(view.timings.firstContentfulPaint), - first_input_delay: toServerDuration(view.timings.firstInputDelay), - first_input_time: toServerDuration(view.timings.firstInputTime), - interaction_to_next_paint: toServerDuration(view.interactionToNextPaint), + first_contentful_paint: toServerDuration(view.initialViewMetrics.firstContentfulPaint), + first_input_delay: toServerDuration(view.initialViewMetrics.firstInputDelay), + first_input_time: toServerDuration(view.initialViewMetrics.firstInputTime), + interaction_to_next_paint: toServerDuration(view.commonViewMetrics.interactionToNextPaint), is_active: view.isActive, name: view.name, - largest_contentful_paint: toServerDuration(view.timings.largestContentfulPaint), - load_event: toServerDuration(view.timings.loadEvent), - loading_time: discardNegativeDuration(toServerDuration(view.loadingTime)), + largest_contentful_paint: toServerDuration(view.initialViewMetrics.largestContentfulPaint), + load_event: toServerDuration(view.initialViewMetrics.loadEvent), + loading_time: discardNegativeDuration(toServerDuration(view.commonViewMetrics.loadingTime)), loading_type: view.loadingType, long_task: { count: view.eventCounts.longTaskCount, @@ -108,13 +108,13 @@ function processViewUpdate( !pageStatesEnabled && pageStates ? mapToForegroundPeriods(pageStates, view.duration) : undefined, // Todo: Remove in the next major release }, feature_flags: featureFlagContext && !isEmptyObject(featureFlagContext) ? featureFlagContext : undefined, - display: view.scrollMetrics + display: view.commonViewMetrics.scroll ? { scroll: { - max_depth: view.scrollMetrics.maxDepth, - max_depth_scroll_height: view.scrollMetrics.maxDepthScrollHeight, - max_depth_scroll_top: view.scrollMetrics.maxDepthScrollTop, - max_depth_time: toServerDuration(view.scrollMetrics.maxDepthTime), + max_depth: view.commonViewMetrics.scroll.maxDepth, + max_depth_scroll_height: view.commonViewMetrics.scroll.maxDepthScrollHeight, + max_depth_scroll_top: view.commonViewMetrics.scroll.maxDepthScrollTop, + max_depth_time: toServerDuration(view.commonViewMetrics.scroll.maxDepthTime), }, } : undefined, diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/interactionCountPolyfill.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/interactionCountPolyfill.ts similarity index 97% rename from packages/rum-core/src/domain/rumEventsCollection/view/interactionCountPolyfill.ts rename to packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/interactionCountPolyfill.ts index e50d184ebf..f80b38b846 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/interactionCountPolyfill.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/interactionCountPolyfill.ts @@ -16,7 +16,7 @@ import type { BrowserWindow, RumPerformanceEventTiming, RumPerformanceObserver, -} from '../../../browser/performanceCollection' +} from '../../../../browser/performanceCollection' let observer: RumPerformanceObserver | undefined diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackCommonViewMetrics.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackCommonViewMetrics.ts new file mode 100644 index 0000000000..5a113f8e2d --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackCommonViewMetrics.ts @@ -0,0 +1,98 @@ +import type { ClocksState, Duration, Observable } from '@datadog/browser-core' +import { noop } from '@datadog/browser-core' +import type { ViewLoadingType } from '../../../../rawRumEvent.types' +import type { RumConfiguration } from '../../../configuration' +import type { LifeCycle } from '../../../lifeCycle' +import type { WebVitalTelemetryDebug } from '../startWebVitalTelemetryDebug' +import type { ScrollMetrics } from './trackScrollMetrics' +import { computeScrollValues, trackScrollMetrics } from './trackScrollMetrics' +import { trackLoadingTime } from './trackLoadingTime' +import { isLayoutShiftSupported, trackCumulativeLayoutShift } from './trackCumulativeLayoutShift' +import { trackInteractionToNextPaint } from './trackInteractionToNextPaint' + +export interface CommonViewMetrics { + loadingTime?: Duration + cumulativeLayoutShift?: number + interactionToNextPaint?: Duration + scroll?: ScrollMetrics +} + +export function trackCommonViewMetrics( + lifeCycle: LifeCycle, + domMutationObservable: Observable, + configuration: RumConfiguration, + scheduleViewUpdate: () => void, + loadingType: ViewLoadingType, + viewStart: ClocksState, + webVitalTelemetryDebug: WebVitalTelemetryDebug +) { + const commonViewMetrics: CommonViewMetrics = {} + + const { stop: stopLoadingTimeTracking, setLoadEvent } = trackLoadingTime( + lifeCycle, + domMutationObservable, + configuration, + loadingType, + viewStart, + (newLoadingTime) => { + commonViewMetrics.loadingTime = newLoadingTime + + // We compute scroll metrics at loading time to ensure we have scroll data when loading the view initially + // This is to ensure that we have the depth data even if the user didn't scroll or if the view is not scrollable. + const { scrollHeight, scrollDepth, scrollTop } = computeScrollValues() + + commonViewMetrics.scroll = { + maxDepth: scrollDepth, + maxDepthScrollHeight: scrollHeight, + maxDepthTime: newLoadingTime, + maxDepthScrollTop: scrollTop, + } + scheduleViewUpdate() + } + ) + + const { stop: stopScrollMetricsTracking } = trackScrollMetrics( + configuration, + viewStart, + (newScrollMetrics) => { + commonViewMetrics.scroll = newScrollMetrics + }, + computeScrollValues + ) + + let stopCLSTracking: () => void + let clsAttributionCollected = false + if (isLayoutShiftSupported()) { + commonViewMetrics.cumulativeLayoutShift = 0 + ;({ stop: stopCLSTracking } = trackCumulativeLayoutShift( + lifeCycle, + (cumulativeLayoutShift, largestLayoutShiftNode, largestLayoutShiftTime) => { + commonViewMetrics.cumulativeLayoutShift = cumulativeLayoutShift + + if (!clsAttributionCollected) { + clsAttributionCollected = true + webVitalTelemetryDebug.addWebVitalTelemetryDebug('CLS', largestLayoutShiftNode, largestLayoutShiftTime) + } + scheduleViewUpdate() + } + )) + } else { + stopCLSTracking = noop + } + + const { stop: stopINPTracking, getInteractionToNextPaint } = trackInteractionToNextPaint(loadingType, lifeCycle) + + return { + stop: () => { + stopLoadingTimeTracking() + stopCLSTracking() + stopScrollMetricsTracking() + stopINPTracking() + }, + setLoadEvent, + getCommonViewMetrics: () => { + commonViewMetrics.interactionToNextPaint = getInteractionToNextPaint() + return commonViewMetrics + }, + } +} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackCumulativeLayoutShift.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackCumulativeLayoutShift.spec.ts new file mode 100644 index 0000000000..ad85d2a3ef --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackCumulativeLayoutShift.spec.ts @@ -0,0 +1,153 @@ +import { relativeNow } from '@datadog/browser-core' +import type { TestSetupBuilder } from '../../../../../test' +import { setup } from '../../../../../test' +import type { LifeCycle } from '../../../lifeCycle' +import { LifeCycleEventType } from '../../../lifeCycle' +import { THROTTLE_VIEW_UPDATE_PERIOD } from '../trackViews' +import type { ViewTest } from '../setupViewTest.specHelper' +import { setupViewTest } from '../setupViewTest.specHelper' + +describe('trackCumulativeLayoutShift', () => { + let setupBuilder: TestSetupBuilder + let viewTest: ViewTest + let isLayoutShiftSupported: boolean + let originalSupportedEntryTypes: PropertyDescriptor | undefined + + function newLayoutShift(lifeCycle: LifeCycle, { value = 0.1, hadRecentInput = false }) { + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ + { + entryType: 'layout-shift', + startTime: relativeNow(), + hadRecentInput, + value, + }, + ]) + } + + beforeEach(() => { + if (!('PerformanceObserver' in window) || !('supportedEntryTypes' in PerformanceObserver)) { + pending('No PerformanceObserver support') + } + setupBuilder = setup() + .withFakeLocation('/foo') + .beforeBuild((buildContext) => { + viewTest = setupViewTest(buildContext) + return viewTest + }) + originalSupportedEntryTypes = Object.getOwnPropertyDescriptor(PerformanceObserver, 'supportedEntryTypes') + isLayoutShiftSupported = true + Object.defineProperty(PerformanceObserver, 'supportedEntryTypes', { + get: () => (isLayoutShiftSupported ? ['layout-shift'] : []), + }) + }) + + afterEach(() => { + if (originalSupportedEntryTypes) { + Object.defineProperty(PerformanceObserver, 'supportedEntryTypes', originalSupportedEntryTypes) + } + setupBuilder.cleanup() + }) + + it('should be initialized to 0', () => { + setupBuilder.build() + const { getViewUpdate, getViewUpdateCount } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).commonViewMetrics.cumulativeLayoutShift).toBe(0) + }) + + it('should be initialized to undefined if layout-shift is not supported', () => { + isLayoutShiftSupported = false + setupBuilder.build() + const { getViewUpdate, getViewUpdateCount } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).commonViewMetrics.cumulativeLayoutShift).toBe(undefined) + }) + + it('should accumulate layout shift values for the first session window', () => { + const { lifeCycle, clock } = setupBuilder.withFakeClock().build() + const { getViewUpdate, getViewUpdateCount } = viewTest + newLayoutShift(lifeCycle, { value: 0.1 }) + clock.tick(100) + newLayoutShift(lifeCycle, { value: 0.2 }) + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdateCount()).toEqual(2) + expect(getViewUpdate(1).commonViewMetrics.cumulativeLayoutShift).toBe(0.3) + }) + + it('should round the cumulative layout shift value to 4 decimals', () => { + const { lifeCycle, clock } = setupBuilder.withFakeClock().build() + const { getViewUpdate, getViewUpdateCount } = viewTest + newLayoutShift(lifeCycle, { value: 1.23456789 }) + clock.tick(100) + newLayoutShift(lifeCycle, { value: 1.11111111111 }) + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdateCount()).toEqual(2) + expect(getViewUpdate(1).commonViewMetrics.cumulativeLayoutShift).toBe(2.3457) + }) + + it('should ignore entries with recent input', () => { + const { lifeCycle, clock } = setupBuilder.withFakeClock().build() + const { getViewUpdate, getViewUpdateCount } = viewTest + + newLayoutShift(lifeCycle, { value: 0.1, hadRecentInput: true }) + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdateCount()).toEqual(1) + expect(getViewUpdate(0).commonViewMetrics.cumulativeLayoutShift).toBe(0) + }) + + it('should create a new session window if the gap is more than 1 second', () => { + const { lifeCycle, clock } = setupBuilder.withFakeClock().build() + const { getViewUpdate, getViewUpdateCount } = viewTest + // first session window + newLayoutShift(lifeCycle, { value: 0.1 }) + clock.tick(100) + newLayoutShift(lifeCycle, { value: 0.2 }) + // second session window + clock.tick(1001) + newLayoutShift(lifeCycle, { value: 0.1 }) + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + expect(getViewUpdateCount()).toEqual(2) + expect(getViewUpdate(1).commonViewMetrics.cumulativeLayoutShift).toBe(0.3) + }) + + it('should create a new session window if the current session window is more than 5 second', () => { + const { lifeCycle, clock } = setupBuilder.withFakeClock().build() + const { getViewUpdate, getViewUpdateCount } = viewTest + newLayoutShift(lifeCycle, { value: 0 }) + for (let i = 0; i < 6; i += 1) { + clock.tick(999) + newLayoutShift(lifeCycle, { value: 0.1 }) + } // window 1: 0.5 | window 2: 0.1 + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + expect(getViewUpdateCount()).toEqual(3) + expect(getViewUpdate(2).commonViewMetrics.cumulativeLayoutShift).toBe(0.5) + }) + + it('should get the max value sessions', () => { + const { lifeCycle, clock } = setupBuilder.withFakeClock().build() + const { getViewUpdate, getViewUpdateCount } = viewTest + // first session window + newLayoutShift(lifeCycle, { value: 0.1 }) + newLayoutShift(lifeCycle, { value: 0.2 }) + // second session window + clock.tick(5001) + newLayoutShift(lifeCycle, { value: 0.1 }) + newLayoutShift(lifeCycle, { value: 0.2 }) + newLayoutShift(lifeCycle, { value: 0.2 }) + // third session window + clock.tick(5001) + newLayoutShift(lifeCycle, { value: 0.2 }) + newLayoutShift(lifeCycle, { value: 0.2 }) + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + expect(getViewUpdateCount()).toEqual(3) + expect(getViewUpdate(2).commonViewMetrics.cumulativeLayoutShift).toBe(0.5) + }) +}) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackCumulativeLayoutShift.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackCumulativeLayoutShift.ts new file mode 100644 index 0000000000..e45a511012 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackCumulativeLayoutShift.ts @@ -0,0 +1,96 @@ +import { round, type RelativeTime, find, ONE_SECOND } from '@datadog/browser-core' +import type { LifeCycle } from '../../../lifeCycle' +import { LifeCycleEventType } from '../../../lifeCycle' +import { supportPerformanceTimingEvent, type RumLayoutShiftTiming } from '../../../../browser/performanceCollection' + +/** + * Track the cumulative layout shifts (CLS). + * Layout shifts are grouped into session windows. + * The minimum gap between session windows is 1 second. + * The maximum duration of a session window is 5 second. + * The session window layout shift value is the sum of layout shifts inside it. + * The CLS value is the max of session windows values. + * + * This yields a new value whenever the CLS value is updated (a higher session window value is computed). + * + * See isLayoutShiftSupported to check for browser support. + * + * Documentation: + * https://web.dev/cls/ + * https://web.dev/evolving-cls/ + * Reference implementation: https://github.com/GoogleChrome/web-vitals/blob/master/src/getCLS.ts + */ +export function trackCumulativeLayoutShift( + lifeCycle: LifeCycle, + callback: (layoutShift: number, largestShiftNode: Node | undefined, largestShiftTime: RelativeTime) => void +) { + let maxClsValue = 0 + + const window = slidingSessionWindow() + const { unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, (entries) => { + for (const entry of entries) { + if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) { + window.update(entry) + + if (window.value() > maxClsValue) { + maxClsValue = window.value() + callback(round(maxClsValue, 4), window.largestLayoutShiftNode(), window.largestLayoutShiftTime()) + } + } + } + }) + + return { + stop, + } +} + +function slidingSessionWindow() { + let value = 0 + let startTime: RelativeTime + let endTime: RelativeTime + + let largestLayoutShift = 0 + let largestLayoutShiftNode: Node | undefined + let largestLayoutShiftTime: RelativeTime + + return { + update: (entry: RumLayoutShiftTiming) => { + const shouldCreateNewWindow = + startTime === undefined || + entry.startTime - endTime >= ONE_SECOND || + entry.startTime - startTime >= 5 * ONE_SECOND + if (shouldCreateNewWindow) { + startTime = endTime = entry.startTime + value = entry.value + largestLayoutShift = 0 + largestLayoutShiftNode = undefined + } else { + value += entry.value + endTime = entry.startTime + } + + if (entry.value > largestLayoutShift) { + largestLayoutShift = entry.value + largestLayoutShiftTime = entry.startTime + + if (entry.sources?.length) { + const largestLayoutShiftSource = find(entry.sources, (s) => s.node?.nodeType === 1) || entry.sources[0] + largestLayoutShiftNode = largestLayoutShiftSource.node + } else { + largestLayoutShiftNode = undefined + } + } + }, + value: () => value, + largestLayoutShiftNode: () => largestLayoutShiftNode, + largestLayoutShiftTime: () => largestLayoutShiftTime, + } +} + +/** + * Check whether `layout-shift` is supported by the browser. + */ +export function isLayoutShiftSupported() { + return supportPerformanceTimingEvent('layout-shift') +} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstContentfulPaint.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstContentfulPaint.spec.ts new file mode 100644 index 0000000000..a2bba947ca --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstContentfulPaint.spec.ts @@ -0,0 +1,64 @@ +import type { RelativeTime } from '@datadog/browser-core' +import { restorePageVisibility, setPageVisibility } from '@datadog/browser-core/test' +import type { RumPerformancePaintTiming } from 'packages/rum-core/src/browser/performanceCollection' +import type { TestSetupBuilder } from '../../../../../test' +import { setup } from '../../../../../test' +import { LifeCycleEventType } from '../../../lifeCycle' +import type { RumConfiguration } from '../../../configuration' +import { resetFirstHidden } from './trackFirstHidden' +import { FCP_MAXIMUM_DELAY, trackFirstContentfulPaint } from './trackFirstContentfulPaint' + +const FAKE_PAINT_ENTRY: RumPerformancePaintTiming = { + entryType: 'paint', + name: 'first-contentful-paint', + startTime: 123 as RelativeTime, +} + +describe('trackFirstContentfulPaint', () => { + let setupBuilder: TestSetupBuilder + let fcpCallback: jasmine.Spy<(value: RelativeTime) => void> + let configuration: RumConfiguration + + beforeEach(() => { + configuration = {} as RumConfiguration + fcpCallback = jasmine.createSpy() + setupBuilder = setup().beforeBuild(({ lifeCycle }) => + trackFirstContentfulPaint(lifeCycle, configuration, fcpCallback) + ) + resetFirstHidden() + }) + + afterEach(() => { + setupBuilder.cleanup() + restorePageVisibility() + resetFirstHidden() + }) + + it('should provide the first contentful paint timing', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_PAINT_ENTRY]) + + expect(fcpCallback).toHaveBeenCalledTimes(1 as RelativeTime) + expect(fcpCallback).toHaveBeenCalledWith(123 as RelativeTime) + }) + + it('should be discarded if the page is hidden', () => { + setPageVisibility('hidden') + const { lifeCycle } = setupBuilder.build() + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_PAINT_ENTRY]) + expect(fcpCallback).not.toHaveBeenCalled() + }) + + it('should be discarded if it is reported after a long time', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ + { + ...FAKE_PAINT_ENTRY, + startTime: FCP_MAXIMUM_DELAY as RelativeTime, + }, + ]) + expect(fcpCallback).not.toHaveBeenCalled() + }) +}) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstContentfulPaint.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstContentfulPaint.ts new file mode 100644 index 0000000000..8f52000769 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstContentfulPaint.ts @@ -0,0 +1,33 @@ +import type { RelativeTime } from '@datadog/browser-core' +import { ONE_MINUTE, find } from '@datadog/browser-core' +import type { RumConfiguration } from '../../../configuration' +import type { LifeCycle } from '../../../lifeCycle' +import { LifeCycleEventType } from '../../../lifeCycle' +import type { RumPerformancePaintTiming } from '../../../../browser/performanceCollection' +import { trackFirstHidden } from './trackFirstHidden' + +// Discard FCP timings above a certain delay to avoid incorrect data +// It happens in some cases like sleep mode or some browser implementations +export const FCP_MAXIMUM_DELAY = 10 * ONE_MINUTE + +export function trackFirstContentfulPaint( + lifeCycle: LifeCycle, + configuration: RumConfiguration, + callback: (fcpTiming: RelativeTime) => void +) { + const firstHidden = trackFirstHidden(configuration) + const { unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, (entries) => { + const fcpEntry = find( + entries, + (entry): entry is RumPerformancePaintTiming => + entry.entryType === 'paint' && + entry.name === 'first-contentful-paint' && + entry.startTime < firstHidden.timeStamp && + entry.startTime < FCP_MAXIMUM_DELAY + ) + if (fcpEntry) { + callback(fcpEntry.startTime) + } + }) + return { stop } +} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackFirstHidden.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstHidden.spec.ts similarity index 98% rename from packages/rum-core/src/domain/rumEventsCollection/view/trackFirstHidden.spec.ts rename to packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstHidden.spec.ts index ddb8bc46c4..b81d882142 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackFirstHidden.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstHidden.spec.ts @@ -1,7 +1,7 @@ import type { RelativeTime } from '@datadog/browser-core' import { DOM_EVENT } from '@datadog/browser-core' import { createNewEvent, restorePageVisibility, setPageVisibility } from '@datadog/browser-core/test' -import type { RumConfiguration } from '../../configuration' +import type { RumConfiguration } from '../../../configuration' import { resetFirstHidden, trackFirstHidden } from './trackFirstHidden' describe('trackFirstHidden', () => { diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackFirstHidden.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstHidden.ts similarity index 95% rename from packages/rum-core/src/domain/rumEventsCollection/view/trackFirstHidden.ts rename to packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstHidden.ts index 0cdecd25d9..e6fe411b0f 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackFirstHidden.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstHidden.ts @@ -1,6 +1,6 @@ import type { RelativeTime } from '@datadog/browser-core' import { addEventListeners, DOM_EVENT } from '@datadog/browser-core' -import type { RumConfiguration } from '../../configuration' +import type { RumConfiguration } from '../../../configuration' let trackFirstHiddenSingleton: { timeStamp: RelativeTime } | undefined let stopListeners: (() => void) | undefined diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstInputTimings.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstInputTimings.spec.ts new file mode 100644 index 0000000000..cd487cfa2f --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstInputTimings.spec.ts @@ -0,0 +1,76 @@ +import type { Duration, RelativeTime } from '@datadog/browser-core' +import { restorePageVisibility, setPageVisibility } from '@datadog/browser-core/test' +import type { TestSetupBuilder } from '../../../../../test' +import { setup } from '../../../../../test' +import { LifeCycleEventType } from '../../../lifeCycle' +import type { RumConfiguration } from '../../../configuration' +import { FAKE_FIRST_INPUT_ENTRY } from '../setupViewTest.specHelper' +import { resetFirstHidden } from './trackFirstHidden' +import { trackFirstInputTimings } from './trackFirstInputTimings' + +describe('firstInputTimings', () => { + let setupBuilder: TestSetupBuilder + let fitCallback: jasmine.Spy< + ({ + firstInputDelay, + firstInputTime, + }: { + firstInputDelay: number + firstInputTime: number + firstInputTarget: Node | undefined + }) => void + > + let configuration: RumConfiguration + + beforeEach(() => { + configuration = {} as RumConfiguration + fitCallback = jasmine.createSpy() + setupBuilder = setup().beforeBuild(({ lifeCycle }) => trackFirstInputTimings(lifeCycle, configuration, fitCallback)) + resetFirstHidden() + }) + + afterEach(() => { + setupBuilder.cleanup() + restorePageVisibility() + resetFirstHidden() + }) + + it('should provide the first input timings', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ + { ...FAKE_FIRST_INPUT_ENTRY, target: document.createElement('button') }, + ]) + expect(fitCallback).toHaveBeenCalledTimes(1) + expect(fitCallback).toHaveBeenCalledWith({ + firstInputDelay: 100, + firstInputTime: 1000, + firstInputTarget: jasmine.any(Node), + }) + }) + + it('should be discarded if the page is hidden', () => { + setPageVisibility('hidden') + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_FIRST_INPUT_ENTRY]) + + expect(fitCallback).not.toHaveBeenCalled() + }) + + it('should be adjusted to 0 if the computed value would be negative due to browser timings imprecisions', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ + { + entryType: 'first-input' as const, + processingStart: 900 as RelativeTime, + startTime: 1000 as RelativeTime, + duration: 0 as Duration, + }, + ]) + + expect(fitCallback).toHaveBeenCalledTimes(1) + expect(fitCallback).toHaveBeenCalledWith({ firstInputDelay: 0, firstInputTime: 1000, firstInputTarget: undefined }) + }) +}) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstInputTimings.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstInputTimings.ts new file mode 100644 index 0000000000..72c7544794 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackFirstInputTimings.ts @@ -0,0 +1,54 @@ +import type { Duration, RelativeTime } from '@datadog/browser-core' +import { elapsed, find } from '@datadog/browser-core' +import type { RumConfiguration } from '../../../configuration' +import type { LifeCycle } from '../../../lifeCycle' +import { LifeCycleEventType } from '../../../lifeCycle' +import type { RumFirstInputTiming } from '../../../../browser/performanceCollection' +import { trackFirstHidden } from './trackFirstHidden' + +/** + * Track the first input occurring during the initial View to return: + * - First Input Delay + * - First Input Time + * Callback is called at most one time. + * Documentation: https://web.dev/fid/ + * Reference implementation: https://github.com/GoogleChrome/web-vitals/blob/master/src/getFID.ts + */ + +export function trackFirstInputTimings( + lifeCycle: LifeCycle, + configuration: RumConfiguration, + callback: ({ + firstInputDelay, + firstInputTime, + firstInputTarget, + }: { + firstInputDelay: Duration + firstInputTime: RelativeTime + firstInputTarget: Node | undefined + }) => void +) { + const firstHidden = trackFirstHidden(configuration) + + const { unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, (entries) => { + const firstInputEntry = find( + entries, + (entry): entry is RumFirstInputTiming => + entry.entryType === 'first-input' && entry.startTime < firstHidden.timeStamp + ) + if (firstInputEntry) { + const firstInputDelay = elapsed(firstInputEntry.startTime, firstInputEntry.processingStart) + callback({ + // Ensure firstInputDelay to be positive, see + // https://bugs.chromium.org/p/chromium/issues/detail?id=1185815 + firstInputDelay: firstInputDelay >= 0 ? firstInputDelay : (0 as Duration), + firstInputTime: firstInputEntry.startTime, + firstInputTarget: firstInputEntry.target, + }) + } + }) + + return { + stop, + } +} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInitialViewMetrics.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInitialViewMetrics.spec.ts new file mode 100644 index 0000000000..49bfc69456 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInitialViewMetrics.spec.ts @@ -0,0 +1,85 @@ +import type { Duration } from '@datadog/browser-core' +import type { TestSetupBuilder } from '../../../../../test' +import { noopWebVitalTelemetryDebug, setup } from '../../../../../test' +import { LifeCycleEventType } from '../../../lifeCycle' +import type { RumConfiguration } from '../../../configuration' +import { FAKE_FIRST_INPUT_ENTRY, FAKE_NAVIGATION_ENTRY, FAKE_PAINT_ENTRY } from '../setupViewTest.specHelper' +import { KEEP_TRACKING_METRICS_AFTER_VIEW_DELAY, trackInitialViewMetrics } from './trackInitialViewMetrics' + +describe('trackInitialViewMetrics', () => { + let setupBuilder: TestSetupBuilder + let scheduleViewUpdateSpy: jasmine.Spy<() => void> + let trackInitialViewMetricsResult: ReturnType + let setLoadEventSpy: jasmine.Spy<(loadEvent: Duration) => void> + let configuration: RumConfiguration + + beforeEach(() => { + configuration = {} as RumConfiguration + scheduleViewUpdateSpy = jasmine.createSpy() + setLoadEventSpy = jasmine.createSpy() + + setupBuilder = setup().beforeBuild(({ lifeCycle }) => { + trackInitialViewMetricsResult = trackInitialViewMetrics( + lifeCycle, + configuration, + noopWebVitalTelemetryDebug, + setLoadEventSpy, + scheduleViewUpdateSpy + ) + return trackInitialViewMetricsResult + }) + }) + + afterEach(() => { + setupBuilder.cleanup() + }) + + it('should merge metrics from various sources', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ + FAKE_NAVIGATION_ENTRY, + FAKE_PAINT_ENTRY, + FAKE_FIRST_INPUT_ENTRY, + ]) + + expect(scheduleViewUpdateSpy).toHaveBeenCalledTimes(3) + expect(trackInitialViewMetricsResult.initialViewMetrics).toEqual({ + firstByte: 123 as Duration, + domComplete: 456 as Duration, + domContentLoaded: 345 as Duration, + domInteractive: 234 as Duration, + firstContentfulPaint: 123 as Duration, + firstInputDelay: 100 as Duration, + firstInputTime: 1000 as Duration, + loadEvent: 567 as Duration, + }) + }) + + it('allows delaying the stop logic', () => { + const { lifeCycle, clock } = setupBuilder.withFakeClock().build() + trackInitialViewMetricsResult.scheduleStop() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_NAVIGATION_ENTRY]) + + expect(scheduleViewUpdateSpy).toHaveBeenCalledTimes(1) + + clock.tick(KEEP_TRACKING_METRICS_AFTER_VIEW_DELAY) + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_PAINT_ENTRY]) + + expect(scheduleViewUpdateSpy).toHaveBeenCalledTimes(1) + }) + + it('calls the `setLoadEvent` callback when the loadEvent timing is known', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ + FAKE_NAVIGATION_ENTRY, + FAKE_PAINT_ENTRY, + FAKE_FIRST_INPUT_ENTRY, + ]) + + expect(setLoadEventSpy).toHaveBeenCalledOnceWith(567 as Duration) + }) +}) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInitialViewMetrics.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInitialViewMetrics.ts new file mode 100644 index 0000000000..26be24dacd --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInitialViewMetrics.ts @@ -0,0 +1,92 @@ +import type { Duration } from '@datadog/browser-core' +import { setTimeout, assign, ONE_MINUTE } from '@datadog/browser-core' +import type { RumConfiguration } from '../../../configuration' +import type { LifeCycle } from '../../../lifeCycle' +import type { WebVitalTelemetryDebug } from '../startWebVitalTelemetryDebug' +import { trackFirstContentfulPaint } from './trackFirstContentfulPaint' +import { trackFirstInputTimings } from './trackFirstInputTimings' +import { trackNavigationTimings } from './trackNavigationTimings' +import { trackLargestContentfulPaint } from './trackLargestContentfulPaint' + +/** + * The initial view can finish quickly, before some metrics can be produced (ex: before the page load + * event, or the first input). Also, we don't want to trigger a view update indefinitely, to avoid + * updates on views that ended a long time ago. Keep watching for metrics after the view ends for a + * limited amount of time. + */ +export const KEEP_TRACKING_METRICS_AFTER_VIEW_DELAY = 5 * ONE_MINUTE + +export interface InitialViewMetrics { + firstContentfulPaint?: Duration + firstByte?: Duration + domInteractive?: Duration + domContentLoaded?: Duration + domComplete?: Duration + loadEvent?: Duration + largestContentfulPaint?: Duration + firstInputDelay?: Duration + firstInputTime?: Duration +} + +export function trackInitialViewMetrics( + lifeCycle: LifeCycle, + configuration: RumConfiguration, + webVitalTelemetryDebug: WebVitalTelemetryDebug, + setLoadEvent: (loadEnd: Duration) => void, + scheduleViewUpdate: () => void +) { + const initialViewMetrics: InitialViewMetrics = {} + + function setMetrics(newMetrics: Partial) { + assign(initialViewMetrics, newMetrics) + scheduleViewUpdate() + } + + const { stop: stopNavigationTracking } = trackNavigationTimings(lifeCycle, (navigationTimings) => { + setLoadEvent(navigationTimings.loadEvent) + setMetrics(navigationTimings) + }) + const { stop: stopFCPTracking } = trackFirstContentfulPaint(lifeCycle, configuration, (firstContentfulPaint) => + setMetrics({ firstContentfulPaint }) + ) + const { stop: stopLCPTracking } = trackLargestContentfulPaint( + lifeCycle, + configuration, + window, + (largestContentfulPaint, lcpElement) => { + webVitalTelemetryDebug.addWebVitalTelemetryDebug('LCP', lcpElement, largestContentfulPaint) + + setMetrics({ + largestContentfulPaint, + }) + } + ) + + const { stop: stopFIDTracking } = trackFirstInputTimings( + lifeCycle, + configuration, + ({ firstInputDelay, firstInputTime, firstInputTarget }) => { + webVitalTelemetryDebug.addWebVitalTelemetryDebug('FID', firstInputTarget, firstInputTime) + + setMetrics({ + firstInputDelay, + firstInputTime, + }) + } + ) + + function stop() { + stopNavigationTracking() + stopFCPTracking() + stopLCPTracking() + stopFIDTracking() + } + + return { + stop, + initialViewMetrics, + scheduleStop: () => { + setTimeout(stop, KEEP_TRACKING_METRICS_AFTER_VIEW_DELAY) + }, + } +} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInteractionToNextPaint.spec.ts similarity index 94% rename from packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.spec.ts rename to packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInteractionToNextPaint.spec.ts index a8c0679eb7..acde12e5d6 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInteractionToNextPaint.spec.ts @@ -5,16 +5,16 @@ import { relativeNow, resetExperimentalFeatures, } from '@datadog/browser-core' -import type { TestSetupBuilder } from '../../../../test' -import { setup } from '../../../../test' +import type { TestSetupBuilder } from '../../../../../test' +import { setup } from '../../../../../test' import type { BrowserWindow, RumFirstInputTiming, RumPerformanceEventTiming, -} from '../../../browser/performanceCollection' -import { ViewLoadingType } from '../../../rawRumEvent.types' -import type { LifeCycle } from '../../lifeCycle' -import { LifeCycleEventType } from '../../lifeCycle' +} from '../../../../browser/performanceCollection' +import { ViewLoadingType } from '../../../../rawRumEvent.types' +import type { LifeCycle } from '../../../lifeCycle' +import { LifeCycleEventType } from '../../../lifeCycle' import { trackInteractionToNextPaint, trackViewInteractionCount, diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInteractionToNextPaint.ts similarity index 90% rename from packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.ts rename to packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInteractionToNextPaint.ts index 8d935c1863..1898b9c353 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackInteractionToNextPaint.ts @@ -1,8 +1,9 @@ -import { type Duration, noop, isExperimentalFeatureEnabled, ExperimentalFeature } from '@datadog/browser-core' -import { supportPerformanceTimingEvent } from '../../../browser/performanceCollection' -import type { RumFirstInputTiming, RumPerformanceEventTiming } from '../../../browser/performanceCollection' -import { LifeCycleEventType, type LifeCycle } from '../../lifeCycle' -import { ViewLoadingType } from '../../../rawRumEvent.types' +import { noop, isExperimentalFeatureEnabled, ExperimentalFeature } from '@datadog/browser-core' +import type { Duration } from '@datadog/browser-core' +import { supportPerformanceTimingEvent } from '../../../../browser/performanceCollection' +import type { RumFirstInputTiming, RumPerformanceEventTiming } from '../../../../browser/performanceCollection' +import { LifeCycleEventType, type LifeCycle } from '../../../lifeCycle' +import { ViewLoadingType } from '../../../../rawRumEvent.types' import { getInteractionCount, initInteractionCountPolyfill } from './interactionCountPolyfill' // Arbitrary value to prevent unnecessary memory usage on views with lots of interactions. diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLargestContentfulPaint.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLargestContentfulPaint.spec.ts new file mode 100644 index 0000000000..e2e70274f9 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLargestContentfulPaint.spec.ts @@ -0,0 +1,78 @@ +import type { RelativeTime } from '@datadog/browser-core' +import { DOM_EVENT } from '@datadog/browser-core' +import { restorePageVisibility, setPageVisibility, createNewEvent } from '@datadog/browser-core/test' +import type { RumLargestContentfulPaintTiming } from '../../../../browser/performanceCollection' +import type { TestSetupBuilder } from '../../../../../test' +import { setup } from '../../../../../test' +import { LifeCycleEventType } from '../../../lifeCycle' +import type { RumConfiguration } from '../../../configuration' +import { resetFirstHidden } from './trackFirstHidden' +import { LCP_MAXIMUM_DELAY, trackLargestContentfulPaint } from './trackLargestContentfulPaint' + +describe('trackLargestContentfulPaint', () => { + let setupBuilder: TestSetupBuilder + let lcpCallback: jasmine.Spy<(value: RelativeTime, lcpElement: Element | undefined) => void> + let eventTarget: Window + let configuration: RumConfiguration + + beforeEach(() => { + configuration = {} as RumConfiguration + lcpCallback = jasmine.createSpy() + eventTarget = document.createElement('div') as unknown as Window + setupBuilder = setup().beforeBuild(({ lifeCycle }) => + trackLargestContentfulPaint(lifeCycle, configuration, eventTarget, lcpCallback) + ) + resetFirstHidden() + }) + + afterEach(() => { + setupBuilder.cleanup() + restorePageVisibility() + resetFirstHidden() + }) + + it('should provide the largest contentful paint timing', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY]) + expect(lcpCallback).toHaveBeenCalledTimes(1 as RelativeTime) + expect(lcpCallback).toHaveBeenCalledWith(789 as RelativeTime, jasmine.any(Element)) + }) + + it('should be discarded if it is reported after a user interaction', () => { + const { lifeCycle } = setupBuilder.build() + + eventTarget.dispatchEvent(createNewEvent(DOM_EVENT.KEY_DOWN, { timeStamp: 1 })) + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY]) + expect(lcpCallback).not.toHaveBeenCalled() + }) + + it('should be discarded if the page is hidden', () => { + setPageVisibility('hidden') + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY]) + + expect(lcpCallback).not.toHaveBeenCalled() + }) + + it('should be discarded if it is reported after a long time', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ + { + ...FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY, + startTime: LCP_MAXIMUM_DELAY as RelativeTime, + }, + ]) + expect(lcpCallback).not.toHaveBeenCalled() + }) +}) + +const FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY: RumLargestContentfulPaintTiming = { + entryType: 'largest-contentful-paint', + startTime: 789 as RelativeTime, + size: 10, + element: document.createElement('div'), +} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLargestContentfulPaint.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLargestContentfulPaint.ts new file mode 100644 index 0000000000..24d6020556 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLargestContentfulPaint.ts @@ -0,0 +1,63 @@ +import type { RelativeTime } from '@datadog/browser-core' +import { DOM_EVENT, ONE_MINUTE, addEventListeners, findLast } from '@datadog/browser-core' +import { LifeCycleEventType, type LifeCycle } from '../../../lifeCycle' +import type { RumConfiguration } from '../../../configuration' +import type { RumLargestContentfulPaintTiming } from '../../../../browser/performanceCollection' +import { trackFirstHidden } from './trackFirstHidden' + +// Discard LCP timings above a certain delay to avoid incorrect data +// It happens in some cases like sleep mode or some browser implementations +export const LCP_MAXIMUM_DELAY = 10 * ONE_MINUTE + +/** + * Track the largest contentful paint (LCP) occurring during the initial View. This can yield + * multiple values, only the most recent one should be used. + * Documentation: https://web.dev/lcp/ + * Reference implementation: https://github.com/GoogleChrome/web-vitals/blob/master/src/getLCP.ts + */ +export function trackLargestContentfulPaint( + lifeCycle: LifeCycle, + configuration: RumConfiguration, + eventTarget: Window, + callback: (lcpTiming: RelativeTime, lcpElement?: Element) => void +) { + const firstHidden = trackFirstHidden(configuration) + + // Ignore entries that come after the first user interaction. According to the documentation, the + // browser should not send largest-contentful-paint entries after a user interact with the page, + // but the web-vitals reference implementation uses this as a safeguard. + let firstInteractionTimestamp = Infinity + const { stop: stopEventListener } = addEventListeners( + configuration, + eventTarget, + [DOM_EVENT.POINTER_DOWN, DOM_EVENT.KEY_DOWN], + (event) => { + firstInteractionTimestamp = event.timeStamp + }, + { capture: true, once: true } + ) + + const { unsubscribe: unsubscribeLifeCycle } = lifeCycle.subscribe( + LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, + (entries) => { + const lcpEntry = findLast( + entries, + (entry): entry is RumLargestContentfulPaintTiming => + entry.entryType === 'largest-contentful-paint' && + entry.startTime < firstInteractionTimestamp && + entry.startTime < firstHidden.timeStamp && + entry.startTime < LCP_MAXIMUM_DELAY + ) + if (lcpEntry) { + callback(lcpEntry.startTime, lcpEntry.element) + } + } + ) + + return { + stop: () => { + stopEventListener() + unsubscribeLifeCycle() + }, + } +} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLoadingTime.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLoadingTime.spec.ts new file mode 100644 index 0000000000..07341374b3 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLoadingTime.spec.ts @@ -0,0 +1,161 @@ +import type { RelativeTime, Duration } from '@datadog/browser-core' +import { addDuration } from '@datadog/browser-core' +import type { TestSetupBuilder } from '../../../../../test' +import { setup } from '../../../../../test' +import type { RumPerformanceNavigationTiming } from '../../../../browser/performanceCollection' +import { LifeCycleEventType } from '../../../lifeCycle' +import { PAGE_ACTIVITY_END_DELAY, PAGE_ACTIVITY_VALIDATION_DELAY } from '../../../waitPageActivityEnd' +import { THROTTLE_VIEW_UPDATE_PERIOD } from '../trackViews' +import type { ViewTest } from '../setupViewTest.specHelper' +import { FAKE_NAVIGATION_ENTRY, setupViewTest } from '../setupViewTest.specHelper' + +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 + +const FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_BEFORE_ACTIVITY_TIMING: RumPerformanceNavigationTiming = { + responseStart: 1 as RelativeTime, + domComplete: 2 as RelativeTime, + domContentLoadedEventEnd: 1 as RelativeTime, + domInteractive: 1 as RelativeTime, + entryType: 'navigation', + loadEventEnd: (BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY * 0.8) as RelativeTime, +} + +const FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_AFTER_ACTIVITY_TIMING: RumPerformanceNavigationTiming = { + responseStart: 1 as RelativeTime, + domComplete: 2 as RelativeTime, + domContentLoadedEventEnd: 1 as RelativeTime, + domInteractive: 1 as RelativeTime, + entryType: 'navigation', + loadEventEnd: (BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY * 1.2) as RelativeTime, +} + +describe('trackLoadingTime', () => { + let setupBuilder: TestSetupBuilder + let viewTest: ViewTest + + beforeEach(() => { + setupBuilder = setup() + .withFakeLocation('/foo') + .beforeBuild((buildContext) => { + viewTest = setupViewTest(buildContext) + return viewTest + }) + .withFakeClock() + }) + + afterEach(() => { + setupBuilder.cleanup() + }) + + it('should have an undefined loading time if there is no activity on a route change', () => { + const { clock } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount, startView } = viewTest + + startView() + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdateCount()).toEqual(3) + expect(getViewUpdate(2).commonViewMetrics.loadingTime).toBeUndefined() + }) + + it('should have a loading time equal to the activity time if there is a unique activity on a route change', () => { + const { domMutationObservable, clock } = setupBuilder.build() + const { getViewUpdate, startView } = viewTest + + startView() + clock.tick(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) + domMutationObservable.notify() + clock.tick(AFTER_PAGE_ACTIVITY_END_DELAY) + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdate(3).commonViewMetrics.loadingTime).toEqual(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) + }) + + it('should use loadEventEnd for initial view when having no activity', () => { + const { lifeCycle, clock } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_NAVIGATION_ENTRY]) + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdateCount()).toEqual(2) + expect(getViewUpdate(1).commonViewMetrics.loadingTime).toEqual(FAKE_NAVIGATION_ENTRY.loadEventEnd) + }) + + it('should use loadEventEnd for initial view when load event is bigger than computed loading time', () => { + const { lifeCycle, domMutationObservable, clock } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + + clock.tick(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ + FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_AFTER_ACTIVITY_TIMING, + ]) + + domMutationObservable.notify() + clock.tick(AFTER_PAGE_ACTIVITY_END_DELAY) + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdateCount()).toEqual(2) + expect(getViewUpdate(1).commonViewMetrics.loadingTime).toEqual( + FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_AFTER_ACTIVITY_TIMING.loadEventEnd + ) + }) + + it('should use computed loading time for initial view when load event is smaller than computed loading time', () => { + const { lifeCycle, domMutationObservable, clock } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + + clock.tick(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ + FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_BEFORE_ACTIVITY_TIMING, + ]) + domMutationObservable.notify() + clock.tick(AFTER_PAGE_ACTIVITY_END_DELAY) + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdateCount()).toEqual(2) + expect(getViewUpdate(1).commonViewMetrics.loadingTime).toEqual(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) + }) + + it('should use computed loading time from time origin for initial view', () => { + // introduce a gap between time origin and tracking start + // ensure that `load event > activity delay` and `load event < activity delay + clock gap` + // to make the test fail if the clock gap is not correctly taken into account + const CLOCK_GAP = (FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_AFTER_ACTIVITY_TIMING.loadEventEnd - + BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY + + 1) as Duration + + setupBuilder.clock!.tick(CLOCK_GAP) + + const { lifeCycle, domMutationObservable, clock } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount } = viewTest + + expect(getViewUpdateCount()).toEqual(1) + + clock.tick(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ + FAKE_NAVIGATION_ENTRY_WITH_LOADEVENT_AFTER_ACTIVITY_TIMING, + ]) + + domMutationObservable.notify() + clock.tick(AFTER_PAGE_ACTIVITY_END_DELAY) + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdateCount()).toEqual(2) + expect(getViewUpdate(1).commonViewMetrics.loadingTime).toEqual( + addDuration(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY, CLOCK_GAP) + ) + }) +}) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLoadingTime.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLoadingTime.ts new file mode 100644 index 0000000000..d3ea07e908 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackLoadingTime.ts @@ -0,0 +1,46 @@ +import type { ClocksState, Duration, Observable } from '@datadog/browser-core' +import { elapsed } from '@datadog/browser-core' +import { waitPageActivityEnd } from '../../../waitPageActivityEnd' +import type { RumConfiguration } from '../../../configuration' +import type { LifeCycle } from '../../../lifeCycle' +import { ViewLoadingType } from '../../../../rawRumEvent.types' + +export function trackLoadingTime( + lifeCycle: LifeCycle, + domMutationObservable: Observable, + configuration: RumConfiguration, + loadType: ViewLoadingType, + viewStart: ClocksState, + callback: (loadingTime: Duration) => void +) { + let isWaitingForLoadEvent = loadType === ViewLoadingType.INITIAL_LOAD + let isWaitingForActivityLoadingTime = true + const loadingTimeCandidates: Duration[] = [] + + function invokeCallbackIfAllCandidatesAreReceived() { + if (!isWaitingForActivityLoadingTime && !isWaitingForLoadEvent && loadingTimeCandidates.length > 0) { + callback(Math.max(...loadingTimeCandidates) as Duration) + } + } + + const { stop } = waitPageActivityEnd(lifeCycle, domMutationObservable, configuration, (event) => { + if (isWaitingForActivityLoadingTime) { + isWaitingForActivityLoadingTime = false + if (event.hadActivity) { + loadingTimeCandidates.push(elapsed(viewStart.timeStamp, event.end)) + } + invokeCallbackIfAllCandidatesAreReceived() + } + }) + + return { + stop, + setLoadEvent: (loadEvent: Duration) => { + if (isWaitingForLoadEvent) { + isWaitingForLoadEvent = false + loadingTimeCandidates.push(loadEvent) + invokeCallbackIfAllCandidatesAreReceived() + } + }, + } +} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackNavigationTimings.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackNavigationTimings.spec.ts new file mode 100644 index 0000000000..7299eb1c91 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackNavigationTimings.spec.ts @@ -0,0 +1,37 @@ +import type { Duration } from '@datadog/browser-core' +import type { TestSetupBuilder } from '../../../../../test' +import { setup } from '../../../../../test' +import { LifeCycleEventType } from '../../../lifeCycle' +import { FAKE_NAVIGATION_ENTRY } from '../setupViewTest.specHelper' +import type { InitialViewMetrics } from './trackInitialViewMetrics' +import { trackNavigationTimings } from './trackNavigationTimings' + +describe('trackNavigationTimings', () => { + let setupBuilder: TestSetupBuilder + let navigationTimingsCallback: jasmine.Spy<(value: Partial) => void> + + beforeEach(() => { + navigationTimingsCallback = jasmine.createSpy() + + setupBuilder = setup().beforeBuild(({ lifeCycle }) => trackNavigationTimings(lifeCycle, navigationTimingsCallback)) + }) + + afterEach(() => { + setupBuilder.cleanup() + }) + + it('should provide navigation timing', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_NAVIGATION_ENTRY]) + + expect(navigationTimingsCallback).toHaveBeenCalledTimes(1) + expect(navigationTimingsCallback).toHaveBeenCalledWith({ + firstByte: 123 as Duration, + domComplete: 456 as Duration, + domContentLoaded: 345 as Duration, + domInteractive: 234 as Duration, + loadEvent: 567 as Duration, + }) + }) +}) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackNavigationTimings.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackNavigationTimings.ts new file mode 100644 index 0000000000..b4bf9c88ee --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackNavigationTimings.ts @@ -0,0 +1,34 @@ +import type { Duration } from '@datadog/browser-core' +import { relativeNow } from '@datadog/browser-core' +import type { LifeCycle } from '../../../lifeCycle' +import { LifeCycleEventType } from '../../../lifeCycle' + +export interface NavigationTimings { + domComplete: Duration + domContentLoaded: Duration + domInteractive: Duration + loadEvent: Duration + firstByte: Duration | undefined +} + +export function trackNavigationTimings(lifeCycle: LifeCycle, callback: (timings: NavigationTimings) => void) { + const { unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, (entries) => { + for (const entry of entries) { + if (entry.entryType === 'navigation') { + callback({ + domComplete: entry.domComplete, + domContentLoaded: entry.domContentLoadedEventEnd, + domInteractive: entry.domInteractive, + loadEvent: entry.loadEventEnd, + // In some cases the value reported is negative or is larger + // than the current page time. Ignore these cases: + // https://github.com/GoogleChrome/web-vitals/issues/137 + // https://github.com/GoogleChrome/web-vitals/issues/162 + firstByte: entry.responseStart >= 0 && entry.responseStart <= relativeNow() ? entry.responseStart : undefined, + }) + } + } + }) + + return { stop } +} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackScrollMetrics.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackScrollMetrics.spec.ts new file mode 100644 index 0000000000..2cb1a0a6d7 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackScrollMetrics.spec.ts @@ -0,0 +1,142 @@ +import type { RelativeTime, TimeStamp, Duration } from '@datadog/browser-core' +import { DOM_EVENT } from '@datadog/browser-core' +import type { Clock } from '@datadog/browser-core/test' +import { createNewEvent, mockClock } from '@datadog/browser-core/test' +import type { TestSetupBuilder } from '../../../../../test' +import { setup } from '../../../../../test' +import { PAGE_ACTIVITY_END_DELAY, PAGE_ACTIVITY_VALIDATION_DELAY } from '../../../waitPageActivityEnd' +import type { RumConfiguration } from '../../../configuration' +import { THROTTLE_VIEW_UPDATE_PERIOD } from '../trackViews' +import type { ViewTest } from '../setupViewTest.specHelper' +import { setupViewTest } from '../setupViewTest.specHelper' +import { THROTTLE_SCROLL_DURATION, type ScrollMetrics, trackScrollMetrics } from './trackScrollMetrics' + +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 + +describe('trackScrollMetrics', () => { + let setupBuilder: TestSetupBuilder + let viewTest: ViewTest + + beforeEach(() => { + setupBuilder = setup() + .withFakeLocation('/foo') + .beforeBuild((buildContext) => { + viewTest = setupViewTest(buildContext) + return viewTest + }) + }) + + afterEach(() => { + setupBuilder.cleanup() + }) + + describe('on scroll', () => { + let scrollMetrics: ScrollMetrics | undefined + let stopTrackScrollMetrics: () => void + let clock: Clock + let configuration: RumConfiguration + + const getMetrics = jasmine.createSpy('getMetrics') + + const newScroll = (scrollParams: { scrollHeight: number; scrollDepth: number; scrollTop: number }) => { + getMetrics.and.returnValue(scrollParams) + + window.dispatchEvent(createNewEvent(DOM_EVENT.SCROLL)) + + clock.tick(THROTTLE_SCROLL_DURATION) + } + + beforeEach(() => { + configuration = {} as RumConfiguration + clock = mockClock() + stopTrackScrollMetrics = trackScrollMetrics( + configuration, + { relative: 0 as RelativeTime, timeStamp: 0 as TimeStamp }, + (s) => (scrollMetrics = s), + getMetrics + ).stop + }) + + afterEach(() => { + stopTrackScrollMetrics() + scrollMetrics = undefined + clock.cleanup() + }) + + it('should update scroll metrics when scrolling the first time', () => { + newScroll({ scrollHeight: 1000, scrollDepth: 500, scrollTop: 100 }) + + expect(scrollMetrics).toEqual({ + maxDepthScrollHeight: 1000, + maxDepth: 500, + maxDepthScrollTop: 100, + maxDepthTime: 1000 as Duration, + }) + }) + + it('should update scroll metrics when scroll depth has increased', () => { + newScroll({ scrollHeight: 1000, scrollDepth: 500, scrollTop: 100 }) + + newScroll({ scrollHeight: 1000, scrollDepth: 600, scrollTop: 200 }) + + expect(scrollMetrics).toEqual({ + maxDepthScrollHeight: 1000, + maxDepth: 600, + maxDepthScrollTop: 200, + maxDepthTime: 2000 as Duration, + }) + }) + + it('should NOT update scroll metrics when scroll depth has not increased', () => { + newScroll({ scrollHeight: 1000, scrollDepth: 600, scrollTop: 200 }) + + newScroll({ scrollHeight: 1000, scrollDepth: 450, scrollTop: 50 }) + + expect(scrollMetrics).toEqual({ + maxDepthScrollHeight: 1000, + maxDepth: 600, + maxDepthScrollTop: 200, + maxDepthTime: 1000 as Duration, + }) + }) + }) + + describe('on load', () => { + beforeEach(() => { + setupBuilder.withFakeClock() + }) + + it('should have an undefined loading time and empty scroll metrics if there is no activity on a route change', () => { + const { clock } = setupBuilder.build() + const { getViewUpdate, getViewUpdateCount, startView } = viewTest + + startView() + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdateCount()).toEqual(3) + expect(getViewUpdate(2).commonViewMetrics.loadingTime).toBeUndefined() + expect(getViewUpdate(2).commonViewMetrics.scroll).toEqual(undefined) + }) + + it('should have a loading time equal to the activity time and scroll metrics if there is a unique activity on a route change', () => { + const { domMutationObservable, clock } = setupBuilder.build() + const { getViewUpdate, startView } = viewTest + + startView() + clock.tick(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) + domMutationObservable.notify() + clock.tick(AFTER_PAGE_ACTIVITY_END_DELAY) + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getViewUpdate(3).commonViewMetrics.loadingTime).toEqual(BEFORE_PAGE_ACTIVITY_VALIDATION_DELAY) + expect(getViewUpdate(3).commonViewMetrics.scroll).toEqual({ + maxDepthScrollHeight: jasmine.any(Number), + maxDepth: jasmine.any(Number), + maxDepthTime: jasmine.any(Number), + maxDepthScrollTop: jasmine.any(Number), + }) + }) + }) +}) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackScrollMetrics.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackScrollMetrics.ts new file mode 100644 index 0000000000..564f329387 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewMetrics/trackScrollMetrics.ts @@ -0,0 +1,69 @@ +import type { ClocksState, Duration } from '@datadog/browser-core' +import { ONE_SECOND, elapsed, relativeNow, throttle, addEventListener, DOM_EVENT } from '@datadog/browser-core' +import type { RumConfiguration } from '../../../configuration' +import { getScrollY } from '../../../../browser/scroll' +import { getViewportDimension } from '../../../../browser/viewportObservable' + +/** Arbitrary scroll throttle duration */ +export const THROTTLE_SCROLL_DURATION = ONE_SECOND + +export interface ScrollMetrics { + maxDepth: number + maxDepthScrollHeight: number + maxDepthScrollTop: number + maxDepthTime: Duration +} + +export function trackScrollMetrics( + configuration: RumConfiguration, + viewStart: ClocksState, + callback: (scrollMetrics: ScrollMetrics) => void, + getScrollValues = computeScrollValues +) { + let maxDepth = 0 + const handleScrollEvent = throttle( + () => { + const { scrollHeight, scrollDepth, scrollTop } = getScrollValues() + + if (scrollDepth > maxDepth) { + const now = relativeNow() + const maxDepthTime = elapsed(viewStart.relative, now) + maxDepth = scrollDepth + callback({ + maxDepth, + maxDepthScrollHeight: scrollHeight, + maxDepthTime, + maxDepthScrollTop: scrollTop, + }) + } + }, + THROTTLE_SCROLL_DURATION, + { leading: false, trailing: true } + ) + + const { stop } = addEventListener(configuration, window, DOM_EVENT.SCROLL, handleScrollEvent.throttled, { + passive: true, + }) + + return { + stop: () => { + handleScrollEvent.cancel() + stop() + }, + } +} + +export function computeScrollValues() { + const scrollTop = getScrollY() + + const { height } = getViewportDimension() + + const scrollHeight = Math.round((document.scrollingElement || document.documentElement).scrollHeight) + const scrollDepth = Math.round(height + scrollTop) + + return { + scrollHeight, + scrollDepth, + scrollTop, + } +}