diff --git a/packages/core/src/tools/specHelper.ts b/packages/core/src/tools/specHelper.ts index 09b5139b0c..a1ff5af86f 100644 --- a/packages/core/src/tools/specHelper.ts +++ b/packages/core/src/tools/specHelper.ts @@ -1,5 +1,5 @@ import { Configuration } from '../domain/configuration' -import { noop } from './utils' +import { noop, objectEntries } from './utils' export const SPEC_ENDPOINTS: Partial = { internalMonitoringEndpoint: 'https://monitoring-intake.com/v1/input/abcde?foo=bar', @@ -144,14 +144,22 @@ class StubXhr { } } -export function createNewEvent(eventName: string) { - let event +export function createNewEvent(eventName: string, properties: { [name: string]: unknown } = {}) { + let event: Event if (typeof Event === 'function') { event = new Event(eventName) } else { event = document.createEvent('Event') event.initEvent(eventName, true, true) } + objectEntries(properties).forEach(([name, value]) => { + // Setting values directly or with a `value` descriptor seems unsupported in IE11 + Object.defineProperty(event, name, { + get() { + return value + }, + }) + }) return event } diff --git a/packages/core/src/tools/utils.ts b/packages/core/src/tools/utils.ts index d49b751f69..37a3059156 100644 --- a/packages/core/src/tools/utils.ts +++ b/packages/core/src/tools/utils.ts @@ -15,6 +15,7 @@ export enum DOM_EVENT { TOUCH_START = 'touchstart', VISIBILITY_CHANGE = 'visibilitychange', DOM_CONTENT_LOADED = 'DOMContentLoaded', + POINTER_DOWN = 'pointerdown', HASH_CHANGE = 'hashchange', PAGE_HIDE = 'pagehide', } @@ -240,7 +241,7 @@ export function objectValues(object: { [key: string]: unknown }) { return values } -export function objectEntries(object: { [key: string]: unknown }) { +export function objectEntries(object: { [key: string]: unknown }): Array<[string, unknown]> { return Object.keys(object).map((key) => [key, object[key]]) } diff --git a/packages/rum/src/browser/performanceCollection.ts b/packages/rum/src/browser/performanceCollection.ts index d3f9719721..3ede835cd0 100644 --- a/packages/rum/src/browser/performanceCollection.ts +++ b/packages/rum/src/browser/performanceCollection.ts @@ -49,11 +49,17 @@ export interface RumPerformanceNavigationTiming { loadEventEnd: number } +export interface RumLargestContentfulPaintTiming { + entryType: 'largest-contentful-paint' + startTime: number +} + export type RumPerformanceEntry = | RumPerformanceResourceTiming | RumPerformanceLongTaskTiming | RumPerformancePaintTiming | RumPerformanceNavigationTiming + | RumLargestContentfulPaintTiming function supportPerformanceObject() { return window.performance !== undefined && 'getEntries' in performance @@ -79,7 +85,7 @@ export function startPerformanceCollection(lifeCycle: LifeCycle, configuration: const observer = new PerformanceObserver( monitor((entries) => handlePerformanceEntries(lifeCycle, configuration, entries.getEntries())) ) - const entryTypes = ['resource', 'navigation', 'longtask', 'paint'] + const entryTypes = ['resource', 'navigation', 'longtask', 'paint', 'largest-contentful-paint'] observer.observe({ entryTypes }) @@ -168,7 +174,8 @@ function handlePerformanceEntries(lifeCycle: LifeCycle, configuration: Configura entry.entryType === 'resource' || entry.entryType === 'navigation' || entry.entryType === 'paint' || - entry.entryType === 'longtask' + entry.entryType === 'longtask' || + entry.entryType === 'largest-contentful-paint' ) { handleRumPerformanceEntry(lifeCycle, configuration, entry as RumPerformanceEntry) } diff --git a/packages/rum/src/domain/rumEventsCollection/view/trackFirstHidden.spec.ts b/packages/rum/src/domain/rumEventsCollection/view/trackFirstHidden.spec.ts index 50d1d17ad4..1fa0407222 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/trackFirstHidden.spec.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/trackFirstHidden.spec.ts @@ -21,7 +21,7 @@ describe('trackFirstHidden', () => { const emitter = document.createElement('div') const firstHidden = trackFirstHidden(emitter) - dispatchPageHideEvent(emitter, 100) + emitter.dispatchEvent(createNewEvent(DOM_EVENT.PAGE_HIDE, { timeStamp: 100 })) expect(firstHidden.timeStamp).toBe(0) }) @@ -30,7 +30,7 @@ describe('trackFirstHidden', () => { const emitter = document.createElement('div') const firstHidden = trackFirstHidden(emitter) - dispatchPageHideEvent(emitter, 100) + emitter.dispatchEvent(createNewEvent(DOM_EVENT.PAGE_HIDE, { timeStamp: 100 })) expect(firstHidden.timeStamp).toBe(100) }) @@ -39,19 +39,9 @@ describe('trackFirstHidden', () => { const emitter = document.createElement('div') const firstHidden = trackFirstHidden(emitter) - dispatchPageHideEvent(emitter, 100) - dispatchPageHideEvent(emitter, 200) + emitter.dispatchEvent(createNewEvent(DOM_EVENT.PAGE_HIDE, { timeStamp: 100 })) + emitter.dispatchEvent(createNewEvent(DOM_EVENT.PAGE_HIDE, { timeStamp: 200 })) expect(firstHidden.timeStamp).toBe(100) }) - - function dispatchPageHideEvent(emitter: Node, timeStamp: number) { - const event = createNewEvent(DOM_EVENT.PAGE_HIDE) - Object.defineProperty(event, 'timeStamp', { - get() { - return timeStamp - }, - }) - emitter.dispatchEvent(event) - } }) diff --git a/packages/rum/src/domain/rumEventsCollection/view/trackTimings.spec.ts b/packages/rum/src/domain/rumEventsCollection/view/trackTimings.spec.ts index 921a69b328..c693f4d2f4 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/trackTimings.spec.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/trackTimings.spec.ts @@ -1,9 +1,19 @@ -import { restorePageVisibility, setPageVisibility } from '@datadog/browser-core' +import { createNewEvent, DOM_EVENT, restorePageVisibility, setPageVisibility } from '@datadog/browser-core' import { setup, TestSetupBuilder } from '../../../../test/specHelper' -import { RumPerformanceNavigationTiming, RumPerformancePaintTiming } from '../../../browser/performanceCollection' +import { + RumLargestContentfulPaintTiming, + RumPerformanceNavigationTiming, + RumPerformancePaintTiming, +} from '../../../browser/performanceCollection' import { LifeCycleEventType } from '../../lifeCycle' import { resetFirstHidden } from './trackFirstHidden' -import { Timings, trackFirstContentfulPaint, trackNavigationTimings, trackTimings } from './trackTimings' +import { + Timings, + trackFirstContentfulPaint, + trackLargestContentfulPaint, + trackNavigationTimings, + trackTimings, +} from './trackTimings' const FAKE_PAINT_ENTRY: RumPerformancePaintTiming = { entryType: 'paint', @@ -19,14 +29,19 @@ const FAKE_NAVIGATION_ENTRY: RumPerformanceNavigationTiming = { loadEventEnd: 567, } +const FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY: RumLargestContentfulPaintTiming = { + entryType: 'largest-contentful-paint', + startTime: 789, +} + describe('trackTimings', () => { let setupBuilder: TestSetupBuilder - let spy: jasmine.Spy<(value: Partial) => void> + let timingsCallback: jasmine.Spy<(value: Partial) => void> beforeEach(() => { - spy = jasmine.createSpy() + timingsCallback = jasmine.createSpy() setupBuilder = setup().beforeBuild(({ lifeCycle }) => { - return trackTimings(lifeCycle, spy) + return trackTimings(lifeCycle, timingsCallback) }) }) @@ -40,8 +55,8 @@ describe('trackTimings', () => { lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_NAVIGATION_ENTRY) lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_PAINT_ENTRY) - expect(spy).toHaveBeenCalledTimes(2) - expect(spy.calls.mostRecent().args[0]).toEqual({ + expect(timingsCallback).toHaveBeenCalledTimes(2) + expect(timingsCallback.calls.mostRecent().args[0]).toEqual({ domComplete: 456, domContentLoaded: 345, domInteractive: 234, @@ -53,12 +68,12 @@ describe('trackTimings', () => { describe('trackNavigationTimings', () => { let setupBuilder: TestSetupBuilder - let spy: jasmine.Spy<(value: Partial) => void> + let navigationTimingsCallback: jasmine.Spy<(value: Partial) => void> beforeEach(() => { - spy = jasmine.createSpy() + navigationTimingsCallback = jasmine.createSpy() setupBuilder = setup().beforeBuild(({ lifeCycle }) => { - return trackNavigationTimings(lifeCycle, spy) + return trackNavigationTimings(lifeCycle, navigationTimingsCallback) }) }) @@ -71,8 +86,8 @@ describe('trackNavigationTimings', () => { lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_NAVIGATION_ENTRY) - expect(spy).toHaveBeenCalledTimes(1) - expect(spy).toHaveBeenCalledWith({ + expect(navigationTimingsCallback).toHaveBeenCalledTimes(1) + expect(navigationTimingsCallback).toHaveBeenCalledWith({ domComplete: 456, domContentLoaded: 345, domInteractive: 234, @@ -83,12 +98,12 @@ describe('trackNavigationTimings', () => { describe('trackFirstContentfulPaint', () => { let setupBuilder: TestSetupBuilder - let spy: jasmine.Spy<(value: number) => void> + let fcpCallback: jasmine.Spy<(value: number) => void> beforeEach(() => { - spy = jasmine.createSpy() + fcpCallback = jasmine.createSpy() setupBuilder = setup().beforeBuild(({ lifeCycle }) => { - return trackFirstContentfulPaint(lifeCycle, spy) + return trackFirstContentfulPaint(lifeCycle, fcpCallback) }) resetFirstHidden() }) @@ -104,14 +119,61 @@ describe('trackFirstContentfulPaint', () => { lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_PAINT_ENTRY) - expect(spy).toHaveBeenCalledTimes(1) - expect(spy).toHaveBeenCalledWith(123) + expect(fcpCallback).toHaveBeenCalledTimes(1) + expect(fcpCallback).toHaveBeenCalledWith(123) }) it('should not set the first contentful paint if the page is hidden', () => { setPageVisibility('hidden') const { lifeCycle } = setupBuilder.build() lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_PAINT_ENTRY) - expect(spy).not.toHaveBeenCalled() + expect(fcpCallback).not.toHaveBeenCalled() + }) +}) + +describe('largestContentfulPaint', () => { + let setupBuilder: TestSetupBuilder + let lcpCallback: jasmine.Spy<(value: number) => void> + let emitter: Element + + beforeEach(() => { + lcpCallback = jasmine.createSpy() + emitter = document.createElement('div') + setupBuilder = setup().beforeBuild(({ lifeCycle }) => { + return trackLargestContentfulPaint(lifeCycle, emitter, lcpCallback) + }) + resetFirstHidden() + }) + + afterEach(() => { + setupBuilder.cleanup() + restorePageVisibility() + resetFirstHidden() + }) + + it('should provide the largest contentful paint timing', () => { + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY) + expect(lcpCallback).toHaveBeenCalledTimes(1) + expect(lcpCallback).toHaveBeenCalledWith(789) + }) + + it('should not be present if it happens after a user interaction', () => { + const { lifeCycle } = setupBuilder.build() + + emitter.dispatchEvent(createNewEvent(DOM_EVENT.KEY_DOWN, { timeStamp: 1 })) + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY) + expect(lcpCallback).not.toHaveBeenCalled() + }) + + it('should not be present if the page is hidden', () => { + setPageVisibility('hidden') + const { lifeCycle } = setupBuilder.build() + + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY) + + expect(lcpCallback).not.toHaveBeenCalled() }) }) diff --git a/packages/rum/src/domain/rumEventsCollection/view/trackTimings.ts b/packages/rum/src/domain/rumEventsCollection/view/trackTimings.ts index ac7fd5445c..b07773d3f6 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/trackTimings.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/trackTimings.ts @@ -1,3 +1,4 @@ +import { addEventListeners, DOM_EVENT, EventEmitter } from '@datadog/browser-core' import { LifeCycle, LifeCycleEventType } from '../../lifeCycle' import { trackFirstHidden } from './trackFirstHidden' @@ -7,6 +8,7 @@ export interface Timings { domContentLoaded?: number domComplete?: number loadEventEnd?: number + largestContentfulPaint?: number } export function trackTimings(lifeCycle: LifeCycle, callback: (timings: Timings) => void) { @@ -20,11 +22,17 @@ export function trackTimings(lifeCycle: LifeCycle, callback: (timings: Timings) const { stop: stopFCPTracking } = trackFirstContentfulPaint(lifeCycle, (firstContentfulPaint) => setTimings({ firstContentfulPaint }) ) + const { stop: stopLCPTracking } = trackLargestContentfulPaint(lifeCycle, window, (largestContentfulPaint) => { + setTimings({ + largestContentfulPaint, + }) + }) return { stop() { stopNavigationTracking() stopFCPTracking() + stopLCPTracking() }, } } @@ -57,3 +65,50 @@ export function trackFirstContentfulPaint(lifeCycle: LifeCycle, callback: (fcp: }) return { stop } } + +/** + * Track the largest contentful paint (LCP) occuring 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, + emitter: EventEmitter, + callback: (value: number) => void +) { + const firstHidden = trackFirstHidden() + + // 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: number = Infinity + const { stop: stopEventListener } = addEventListeners( + emitter, + [DOM_EVENT.POINTER_DOWN, DOM_EVENT.KEY_DOWN], + (event) => { + firstInteractionTimestamp = event.timeStamp + }, + { capture: true, once: true } + ) + + const { unsubscribe: unsubcribeLifeCycle } = lifeCycle.subscribe( + LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, + (entry) => { + if ( + entry.entryType === 'largest-contentful-paint' && + entry.startTime < firstInteractionTimestamp && + entry.startTime < firstHidden.timeStamp + ) { + callback(entry.startTime) + } + } + ) + + return { + stop() { + stopEventListener() + unsubcribeLifeCycle() + }, + } +} diff --git a/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts b/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts index a7c273a0b4..540496d435 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/trackViews.spec.ts @@ -1,6 +1,10 @@ import { createRawRumEvent } from '../../../../test/fixtures' import { setup, TestSetupBuilder } from '../../../../test/specHelper' -import { RumPerformanceNavigationTiming, RumPerformancePaintTiming } from '../../../browser/performanceCollection' +import { + RumLargestContentfulPaintTiming, + RumPerformanceNavigationTiming, + RumPerformancePaintTiming, +} from '../../../browser/performanceCollection' import { RawRumEvent, RumEventCategory } from '../../../types' import { RumEventType } from '../../../typesV2' import { LifeCycleEventType } from '../../lifeCycle' @@ -20,6 +24,10 @@ const FAKE_PAINT_ENTRY: RumPerformancePaintTiming = { name: 'first-contentful-paint', startTime: 123, } +const FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY: RumLargestContentfulPaintTiming = { + entryType: 'largest-contentful-paint', + startTime: 789, +} const FAKE_NAVIGATION_ENTRY: RumPerformanceNavigationTiming = { domComplete: 456, domContentLoadedEventEnd: 345, @@ -491,6 +499,7 @@ describe('rum view measures', () => { expect(getViewEvent(0).timings).toEqual({}) lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_PAINT_ENTRY) + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY) lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_NAVIGATION_ENTRY) expect(getHandledCount()).toEqual(1) @@ -502,6 +511,7 @@ describe('rum view measures', () => { domContentLoaded: 345, domInteractive: 234, firstContentfulPaint: 123, + largestContentfulPaint: 789, loadEventEnd: 567, }) expect(getViewEvent(2).timings).toEqual({}) @@ -525,6 +535,7 @@ describe('rum view measures', () => { expect(getHandledCount()).toEqual(3) lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_PAINT_ENTRY) + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY) lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_NAVIGATION_ENTRY) clock.tick(THROTTLE_VIEW_UPDATE_PERIOD) @@ -552,6 +563,7 @@ describe('rum view measures', () => { domContentLoaded: 345, domInteractive: 234, firstContentfulPaint: 123, + largestContentfulPaint: 789, loadEventEnd: 567, }) }) diff --git a/packages/rum/src/domain/rumEventsCollection/view/viewCollection.spec.ts b/packages/rum/src/domain/rumEventsCollection/view/viewCollection.spec.ts index 193ab3f0bf..343f7ddef2 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/viewCollection.spec.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/viewCollection.spec.ts @@ -44,6 +44,7 @@ describe('viewCollection', () => { domContentLoaded: 10, domInteractive: 10, firstContentfulPaint: 10, + largestContentfulPaint: 10, loadEventEnd: 10, }, } @@ -117,6 +118,7 @@ describe('viewCollection V2', () => { domContentLoaded: 10, domInteractive: 10, firstContentfulPaint: 10, + largestContentfulPaint: 10, loadEventEnd: 10, }, } @@ -140,6 +142,7 @@ describe('viewCollection V2', () => { count: 10, }, firstContentfulPaint: 10 * 1e6, + largestContentfulPaint: 10 * 1e6, loadEventEnd: 10 * 1e6, loadingTime: 20 * 1e6, loadingType: ViewLoadingType.INITIAL_LOAD, diff --git a/packages/rum/src/domain/rumEventsCollection/view/viewCollection.ts b/packages/rum/src/domain/rumEventsCollection/view/viewCollection.ts index b91cb509f0..9eb0c1028c 100644 --- a/packages/rum/src/domain/rumEventsCollection/view/viewCollection.ts +++ b/packages/rum/src/domain/rumEventsCollection/view/viewCollection.ts @@ -61,6 +61,7 @@ function processViewUpdateV2(view: View) { count: view.eventCounts.errorCount, }, firstContentfulPaint: msToNs(view.timings.firstContentfulPaint), + largestContentfulPaint: msToNs(view.timings.largestContentfulPaint), loadEventEnd: msToNs(view.timings.loadEventEnd), loadingTime: msToNs(view.loadingTime), loadingType: view.loadingType, diff --git a/packages/rum/src/typesV2.ts b/packages/rum/src/typesV2.ts index 4ccf9b5022..4899f5e23f 100644 --- a/packages/rum/src/typesV2.ts +++ b/packages/rum/src/typesV2.ts @@ -57,6 +57,7 @@ export interface RumViewEventV2 { view: { loadingType: ViewLoadingType firstContentfulPaint?: number + largestContentfulPaint?: number domInteractive?: number domContentLoaded?: number domComplete?: number