From a5bb271c758703e167cfe755d22ea065b4a5d260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Zugmeyer?= Date: Wed, 18 Nov 2020 16:28:53 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20[RUMF-777]=20implement=20Cumula?= =?UTF-8?q?tive=20Layout=20Shift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rum/src/browser/performanceCollection.ts | 22 ++++++- .../view/trackViews.spec.ts | 59 +++++++++++++++++++ .../rumEventsCollection/view/trackViews.ts | 44 +++++++++++++- .../view/viewCollection.spec.ts | 2 + .../view/viewCollection.ts | 1 + packages/rum/src/typesV2.ts | 1 + 6 files changed, 125 insertions(+), 4 deletions(-) diff --git a/packages/rum/src/browser/performanceCollection.ts b/packages/rum/src/browser/performanceCollection.ts index 7d4cce6297..48a3d4d629 100644 --- a/packages/rum/src/browser/performanceCollection.ts +++ b/packages/rum/src/browser/performanceCollection.ts @@ -68,6 +68,12 @@ export interface RumFirstInputTiming { processingStart: number } +export interface RumLayoutShiftTiming { + entryType: 'layout-shift' + value: number + hadRecentInput: boolean +} + export type RumPerformanceEntry = | RumPerformanceResourceTiming | RumPerformanceLongTaskTiming @@ -75,12 +81,13 @@ export type RumPerformanceEntry = | RumPerformanceNavigationTiming | RumLargestContentfulPaintTiming | RumFirstInputTiming + | RumLayoutShiftTiming function supportPerformanceObject() { return window.performance !== undefined && 'getEntries' in performance } -function supportPerformanceTimingEvent(entryType: string) { +export function supportPerformanceTimingEvent(entryType: string) { return ( (window as BrowserWindow).PerformanceObserver && PerformanceObserver.supportedEntryTypes !== undefined && @@ -100,7 +107,15 @@ export function startPerformanceCollection(lifeCycle: LifeCycle, configuration: const observer = new PerformanceObserver( monitor((entries) => handlePerformanceEntries(lifeCycle, configuration, entries.getEntries())) ) - const entryTypes = ['resource', 'navigation', 'longtask', 'paint', 'largest-contentful-paint', 'first-input'] + const entryTypes = [ + 'resource', + 'navigation', + 'longtask', + 'paint', + 'largest-contentful-paint', + 'first-input', + 'layout-shift', + ] observer.observe({ entryTypes }) @@ -267,7 +282,8 @@ function handlePerformanceEntries(lifeCycle: LifeCycle, configuration: Configura entry.entryType === 'paint' || entry.entryType === 'longtask' || entry.entryType === 'largest-contentful-paint' || - entry.entryType === 'first-input' + entry.entryType === 'first-input' || + entry.entryType === 'layout-shift' ) { handleRumPerformanceEntry(lifeCycle, configuration, entry as RumPerformanceEntry) } diff --git a/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts b/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts index 540496d435..259344842e 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts @@ -885,4 +885,63 @@ describe('rum view measures', () => { expect(getHandledCount()).toEqual(3) }) }) + + describe('cumulativeLayoutShift', () => { + let isLayoutShiftSupported: boolean + beforeEach(() => { + isLayoutShiftSupported = true + spyOnProperty(PerformanceObserver, 'supportedEntryTypes', 'get').and.callFake(() => { + return isLayoutShiftSupported ? ['layout-shift'] : [] + }) + }) + + it('should be initialized to 0', () => { + setupBuilder.build() + expect(getHandledCount()).toEqual(1) + expect(getViewEvent(0).cumulativeLayoutShift).toBe(0) + }) + + it('should be initialized to 0 if layout-shift is not supported', () => { + isLayoutShiftSupported = false + setupBuilder.build() + expect(getHandledCount()).toEqual(1) + expect(getViewEvent(0).cumulativeLayoutShift).toBe(undefined) + }) + + it('should accmulate layout shift values', () => { + const { lifeCycle, clock } = setupBuilder.withFakeClock().build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, { + entryType: 'layout-shift', + hadRecentInput: false, + value: 0.1, + }) + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, { + entryType: 'layout-shift', + hadRecentInput: false, + value: 0.2, + }) + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getHandledCount()).toEqual(2) + expect(getViewEvent(1).cumulativeLayoutShift).toBe(0.1 + 0.2) + }) + + it('should ignore entries with recent input', () => { + const { lifeCycle, clock } = setupBuilder.withFakeClock().build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, { + entryType: 'layout-shift', + hadRecentInput: true, + value: 0.1, + }) + + clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) + + expect(getHandledCount()).toEqual(1) + expect(getViewEvent(0).cumulativeLayoutShift).toBe(0) + }) + }) }) diff --git a/packages/rum/src/domain/rumEventsCollection/view/trackViews.ts b/packages/rum/src/domain/rumEventsCollection/view/trackViews.ts index 7d8cb5e864..effdfb9f2a 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/trackViews.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/trackViews.ts @@ -1,5 +1,6 @@ -import { addEventListener, DOM_EVENT, generateUUID, monitor, ONE_MINUTE, throttle } from '@datadog/browser-core' +import { addEventListener, DOM_EVENT, generateUUID, monitor, noop, ONE_MINUTE, throttle } from '@datadog/browser-core' +import { supportPerformanceTimingEvent } from '../../../browser/performanceCollection' import { LifeCycle, LifeCycleEventType } from '../../lifeCycle' import { EventCounts, trackEventCounts } from '../../trackEventCounts' import { waitIdlePageActivity } from '../../trackPageActivities' @@ -16,6 +17,7 @@ export interface View { duration: number loadingTime?: number | undefined loadingType: ViewLoadingType + cumulativeLayoutShift?: number } export interface ViewCreatedEvent { @@ -105,6 +107,7 @@ function newView( } let timings: Timings = {} let documentVersion = 0 + let cumulativeLayoutShift = isLayoutShiftSupported() ? 0 : undefined let loadingTime: number | undefined let endTime: number | undefined let location: Location = { ...initialLocation } @@ -132,12 +135,18 @@ function newView( const { stop: stopActivityLoadingTimeTracking } = trackActivityLoadingTime(lifeCycle, setActivityLoadingTime) + const { stop: stopCLSTracking } = trackLayoutShift(lifeCycle, (layoutShift) => { + cumulativeLayoutShift = (cumulativeLayoutShift || 0) + layoutShift + scheduleViewUpdate() + }) + // Initial view update triggerViewUpdate() function triggerViewUpdate() { documentVersion += 1 lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + cumulativeLayoutShift, documentVersion, eventCounts, id, @@ -157,6 +166,7 @@ function newView( endTime = performance.now() stopEventCountsTracking() stopActivityLoadingTimeTracking() + stopCLSTracking() }, isDifferentView(otherLocation: Location) { return ( @@ -250,3 +260,35 @@ function trackActivityLoadingTime(lifeCycle: LifeCycle, callback: (loadingTimeVa return { stop: stopWaitIdlePageActivity } } + +/** + * Track layout shifts (LS) occuring during the Views. This yields multiple values that can be + * added up to compute the cumulated layout shift (CLS). + * + * See isLayoutShiftSupported to check for browser support. + * + * Documentation: https://web.dev/cls/ + * Reference implementation: https://github.com/GoogleChrome/web-vitals/blob/master/src/getCLS.ts + */ +function trackLayoutShift(lifeCycle: LifeCycle, callback: (layoutShift: number) => void) { + let stop + if (isLayoutShiftSupported()) { + ;({ unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, (entry) => { + if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) { + callback(entry.value) + } + })) + } else { + stop = noop + } + return { + stop, + } +} + +/** + * Check whether `layout-shift` is supported by the browser. + */ +function isLayoutShiftSupported() { + return supportPerformanceTimingEvent('layout-shift') +} diff --git a/packages/rum/src/domain/rumEventsCollection/view/viewCollection.spec.ts b/packages/rum/src/domain/rumEventsCollection/view/viewCollection.spec.ts index 53c149322b..e135142269 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/viewCollection.spec.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/viewCollection.spec.ts @@ -99,6 +99,7 @@ describe('viewCollection V2', () => { it('should create view from view update', () => { const { lifeCycle, rawRumEventsV2 } = setupBuilder.build() const view = { + cumulativeLayoutShift: 1, documentVersion: 3, duration: 100, eventCounts: { @@ -136,6 +137,7 @@ describe('viewCollection V2', () => { action: { count: 10, }, + cumulativeLayoutShift: 1, domComplete: 10 * 1e6, domContentLoaded: 10 * 1e6, domInteractive: 10 * 1e6, diff --git a/packages/rum/src/domain/rumEventsCollection/view/viewCollection.ts b/packages/rum/src/domain/rumEventsCollection/view/viewCollection.ts index 484e8e3167..fad34c26e8 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/viewCollection.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/viewCollection.ts @@ -54,6 +54,7 @@ function processViewUpdateV2(view: View) { action: { count: view.eventCounts.userActionCount, }, + cumulativeLayoutShift: view.cumulativeLayoutShift, domComplete: msToNs(view.timings.domComplete), domContentLoaded: msToNs(view.timings.domContentLoaded), domInteractive: msToNs(view.timings.domInteractive), diff --git a/packages/rum/src/typesV2.ts b/packages/rum/src/typesV2.ts index 1cb711db45..4ef9086388 100644 --- a/packages/rum/src/typesV2.ts +++ b/packages/rum/src/typesV2.ts @@ -58,6 +58,7 @@ export interface RumViewEventV2 { loadingType: ViewLoadingType firstContentfulPaint?: number firstInputDelay?: number + cumulativeLayoutShift?: number largestContentfulPaint?: number domInteractive?: number domContentLoaded?: number From 8b5f580c17cd3fbdf0eda033def5cc8669c6db5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Zugmeyer?= Date: Mon, 23 Nov 2020 15:17:06 +0100 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=85=20[RUMF-777]=20ignore=20tests=20o?= =?UTF-8?q?n=20browsers=20without=20required=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rum/src/domain/rumEventsCollection/view/trackViews.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts b/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts index 259344842e..d1141df059 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts @@ -889,6 +889,9 @@ describe('rum view measures', () => { describe('cumulativeLayoutShift', () => { let isLayoutShiftSupported: boolean beforeEach(() => { + if (!('PerformanceObserver' in window) || !('supportedEntryTypes' in PerformanceObserver)) { + pending('No PerformanceObserver support') + } isLayoutShiftSupported = true spyOnProperty(PerformanceObserver, 'supportedEntryTypes', 'get').and.callFake(() => { return isLayoutShiftSupported ? ['layout-shift'] : [] From 20b7a72f6201d3a8e051b568e9aba4801c382dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Zugmeyer?= Date: Mon, 23 Nov 2020 16:13:19 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=85=20[RUMF-777]=20adjust=20test=20ti?= =?UTF-8?q?tle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rum/src/domain/rumEventsCollection/view/trackViews.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts b/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts index d1141df059..01bf6d42e2 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts @@ -904,7 +904,7 @@ describe('rum view measures', () => { expect(getViewEvent(0).cumulativeLayoutShift).toBe(0) }) - it('should be initialized to 0 if layout-shift is not supported', () => { + it('should be initialized to undefined if layout-shift is not supported', () => { isLayoutShiftSupported = false setupBuilder.build() expect(getHandledCount()).toEqual(1) From 869d989c1320797f0e56ff8b164535d517772210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Zugmeyer?= Date: Mon, 23 Nov 2020 17:32:03 +0100 Subject: [PATCH 4/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=F0=9F=91=8C=20[RUMF-777]?= =?UTF-8?q?=20invoke=20`trackLayoutShift`=20only=20if=20layout-shift=20is?= =?UTF-8?q?=20supported?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rumEventsCollection/view/trackViews.ts | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/rum/src/domain/rumEventsCollection/view/trackViews.ts b/packages/rum/src/domain/rumEventsCollection/view/trackViews.ts index effdfb9f2a..6b74d31ba1 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/trackViews.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/trackViews.ts @@ -107,7 +107,7 @@ function newView( } let timings: Timings = {} let documentVersion = 0 - let cumulativeLayoutShift = isLayoutShiftSupported() ? 0 : undefined + let cumulativeLayoutShift: number | undefined let loadingTime: number | undefined let endTime: number | undefined let location: Location = { ...initialLocation } @@ -135,10 +135,16 @@ function newView( const { stop: stopActivityLoadingTimeTracking } = trackActivityLoadingTime(lifeCycle, setActivityLoadingTime) - const { stop: stopCLSTracking } = trackLayoutShift(lifeCycle, (layoutShift) => { - cumulativeLayoutShift = (cumulativeLayoutShift || 0) + layoutShift - scheduleViewUpdate() - }) + let stopCLSTracking: () => void + if (isLayoutShiftSupported()) { + cumulativeLayoutShift = 0 + ;({ stop: stopCLSTracking } = trackLayoutShift(lifeCycle, (layoutShift) => { + cumulativeLayoutShift! += layoutShift + scheduleViewUpdate() + })) + } else { + stopCLSTracking = noop + } // Initial view update triggerViewUpdate() @@ -271,16 +277,12 @@ function trackActivityLoadingTime(lifeCycle: LifeCycle, callback: (loadingTimeVa * Reference implementation: https://github.com/GoogleChrome/web-vitals/blob/master/src/getCLS.ts */ function trackLayoutShift(lifeCycle: LifeCycle, callback: (layoutShift: number) => void) { - let stop - if (isLayoutShiftSupported()) { - ;({ unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, (entry) => { - if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) { - callback(entry.value) - } - })) - } else { - stop = noop - } + const { unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, (entry) => { + if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) { + callback(entry.value) + } + }) + return { stop, }