From 73c2fdc4cb64d03c61a207066a73fbdd80a7c1b9 Mon Sep 17 00:00:00 2001 From: Aymeric Date: Tue, 25 Jul 2023 17:38:06 +0200 Subject: [PATCH] Implement INP --- .../core/src/tools/experimentalFeatures.ts | 1 + .../src/browser/performanceCollection.ts | 37 ++++- .../view/interactionCountPolyfill.ts | 40 +++++ .../view/trackInitialViewTimings.spec.ts | 2 + .../view/trackInteractionToNextPaint.spec.ts | 153 ++++++++++++++++++ .../view/trackInteractionToNextPaint.ts | 114 +++++++++++++ .../view/trackViewMetrics.ts | 10 +- .../rumEventsCollection/view/trackViews.ts | 5 +- .../view/viewCollection.spec.ts | 2 + .../view/viewCollection.ts | 1 + packages/rum-core/src/rawRumEvent.types.ts | 1 + 11 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/interactionCountPolyfill.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.spec.ts create mode 100644 packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.ts diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts index 63959816e3..b62737f8b2 100644 --- a/packages/core/src/tools/experimentalFeatures.ts +++ b/packages/core/src/tools/experimentalFeatures.ts @@ -18,6 +18,7 @@ export enum ExperimentalFeature { COLLECT_FLUSH_REASON = 'collect_flush_reason', NO_RESOURCE_DURATION_FROZEN_STATE = 'no_resource_duration_frozen_state', SCROLLMAP = 'scrollmap', + INTERACTION_TO_NEXT_PAINT = 'interaction_to_next_paint', } const enabledExperimentalFeatures: Set = new Set() diff --git a/packages/rum-core/src/browser/performanceCollection.ts b/packages/rum-core/src/browser/performanceCollection.ts index 031be3bce2..4f24aa9c5f 100644 --- a/packages/rum-core/src/browser/performanceCollection.ts +++ b/packages/rum-core/src/browser/performanceCollection.ts @@ -21,6 +21,17 @@ import { FAKE_INITIAL_DOCUMENT, isAllowedRequestUrl } from '../domain/rumEventsC import { getDocumentTraceId } from '../domain/tracing/getDocumentTraceId' import type { PerformanceEntryRepresentation } from '../domainContext.types' +type RumPerformanceObserverConstructor = new (callback: PerformanceObserverCallback) => RumPerformanceObserver + +export interface BrowserWindow extends Window { + PerformanceObserver: RumPerformanceObserverConstructor + performance: Performance & { interactionCount?: number } +} + +export interface RumPerformanceObserver extends PerformanceObserver { + observe(options?: PerformanceObserverInit & { durationThreshold: number }): void +} + export interface RumPerformanceResourceTiming { entryType: 'resource' initiatorType: string @@ -74,6 +85,15 @@ export interface RumFirstInputTiming { entryType: 'first-input' startTime: RelativeTime processingStart: RelativeTime + duration: Duration + interactionId?: number +} + +export interface RumEventTiming { + entryType: 'event' + startTime: RelativeTime + duration: Duration + interactionId?: number } export interface RumLayoutShiftTiming { @@ -90,6 +110,7 @@ export type RumPerformanceEntry = | RumPerformanceNavigationTiming | RumLargestContentfulPaintTiming | RumFirstInputTiming + | RumEventTiming | RumLayoutShiftTiming function supportPerformanceObject() { @@ -121,15 +142,21 @@ export function startPerformanceCollection(lifeCycle: LifeCycle, configuration: handleRumPerformanceEntries(lifeCycle, configuration, entries.getEntries()) ) const mainEntries = ['resource', 'navigation', 'longtask', 'paint'] - const experimentalEntries = ['largest-contentful-paint', 'first-input', 'layout-shift'] + const experimentalEntries = ['largest-contentful-paint', 'first-input', 'layout-shift', 'event'] try { // Experimental entries are not retrieved by performance.getEntries() // use a single PerformanceObserver with buffered flag by type // to get values that could happen before SDK init experimentalEntries.forEach((type) => { - const observer = new PerformanceObserver(handlePerformanceEntryList) - observer.observe({ type, buffered: true }) + const observer = new (window as BrowserWindow).PerformanceObserver(handlePerformanceEntryList) + observer.observe({ + type, + buffered: true, + // durationThreshold only impact PerformanceEventTiming entries used for INP computation which requires a threshold at 40 (default is 104ms) + // cf: https://github.com/GoogleChrome/web-vitals/blob/3806160ffbc93c3c4abf210a167b81228172b31c/src/onINP.ts#L209 + durationThreshold: 40, + }) }) } catch (e) { // Some old browser versions (ex: chrome 67) don't support the PerformanceObserver type and buffered options @@ -227,6 +254,7 @@ function retrieveFirstInputTiming(callback: (timing: RumFirstInputTiming) => voi entryType: 'first-input', processingStart: relativeNow(), startTime: evt.timeStamp as RelativeTime, + duration: 0 as Duration, } if (evt.type === DOM_EVENT.POINTER_DOWN) { @@ -304,7 +332,8 @@ function handleRumPerformanceEntries( entry.entryType === 'longtask' || entry.entryType === 'largest-contentful-paint' || entry.entryType === 'first-input' || - entry.entryType === 'layout-shift' + entry.entryType === 'layout-shift' || + entry.entryType === 'event' ) as RumPerformanceEntry[] const rumAllowedPerformanceEntries = rumPerformanceEntries.filter( diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/interactionCountPolyfill.ts b/packages/rum-core/src/domain/rumEventsCollection/view/interactionCountPolyfill.ts new file mode 100644 index 0000000000..0684d32180 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/interactionCountPolyfill.ts @@ -0,0 +1,40 @@ +/** + * interactionCount polyfill + * Reference implementation: https://github.com/GoogleChrome/web-vitals/blob/main/src/lib/polyfills/interactionCountPolyfill.ts + */ + +import type { BrowserWindow, RumEventTiming, RumPerformanceObserver } from '../../../browser/performanceCollection' + +let observer: RumPerformanceObserver | undefined + +let interactionCountEstimate = 0 +let minKnownInteractionId = Infinity +let maxKnownInteractionId = 0 + +export function initInteractionCountPolyfill() { + if ('interactionCount' in performance || observer) { + return + } + + observer = new (window as BrowserWindow).PerformanceObserver((entries: PerformanceObserverEntryList) => { + entries.getEntries().forEach((e) => { + const entry = e as unknown as RumEventTiming + + if (entry.interactionId) { + minKnownInteractionId = Math.min(minKnownInteractionId, entry.interactionId) + maxKnownInteractionId = Math.max(maxKnownInteractionId, entry.interactionId) + + interactionCountEstimate = maxKnownInteractionId ? (maxKnownInteractionId - minKnownInteractionId) / 7 + 1 : 0 + } + }) + }) + + observer.observe({ type: 'event', buffered: true, durationThreshold: 0 }) +} + +/** + * Returns the `interactionCount` value using the native API (if available) + * or the polyfill estimate in this module. + */ +export const getInteractionCount = () => + observer ? interactionCountEstimate : (window as BrowserWindow).performance.interactionCount! || 0 diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackInitialViewTimings.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackInitialViewTimings.spec.ts index 033b8e72f3..d74ee012a2 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackInitialViewTimings.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/trackInitialViewTimings.spec.ts @@ -47,6 +47,7 @@ const FAKE_FIRST_INPUT_ENTRY: RumFirstInputTiming = { entryType: 'first-input', processingStart: 1100 as RelativeTime, startTime: 1000 as RelativeTime, + duration: 10 as Duration, } describe('trackInitialViewTimings', () => { @@ -294,6 +295,7 @@ describe('firstInputTimings', () => { entryType: 'first-input' as const, processingStart: 900 as RelativeTime, startTime: 1000 as RelativeTime, + duration: 10 as Duration, }, ]) diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.spec.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.spec.ts new file mode 100644 index 0000000000..cf204c48b1 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.spec.ts @@ -0,0 +1,153 @@ +import type { Duration } from '@datadog/browser-core' +import { + ExperimentalFeature, + addExperimentalFeatures, + relativeNow, + resetExperimentalFeatures, +} from '@datadog/browser-core' +import type { TestSetupBuilder } from '../../../../test' +import { setup } from '../../../../test' +import type { BrowserWindow, RumEventTiming, RumFirstInputTiming } from '../../../browser/performanceCollection' +import { ViewLoadingType } from '../../../rawRumEvent.types' +import type { LifeCycle } from '../../lifeCycle' +import { LifeCycleEventType } from '../../lifeCycle' +import { + trackInteractionToNextPaint, + trackViewInteractionCount, + isInteractionToNextPaintSupported, +} from './trackInteractionToNextPaint' + +describe('trackInteractionToNextPaint', () => { + let setupBuilder: TestSetupBuilder + let interactionCountStub: ReturnType + let getInteractionToNextPaint: () => Duration | undefined + + function newInteraction( + lifeCycle: LifeCycle, + { interactionId, duration = 40 as Duration, entryType = 'event' }: Partial + ) { + if (interactionId) { + interactionCountStub.incrementInteractionCount() + } + lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [ + { + entryType, + startTime: relativeNow(), + duration, + interactionId, + processingStart: relativeNow(), + }, + ]) + } + + beforeEach(() => { + if (!isInteractionToNextPaintSupported()) { + pending('No PerformanceObserver support') + } + + interactionCountStub = subInteractionCount() + setupBuilder = setup().beforeBuild(({ lifeCycle }) => { + ;({ getInteractionToNextPaint } = trackInteractionToNextPaint(ViewLoadingType.INITIAL_LOAD, lifeCycle)) + }) + }) + + afterEach(() => { + interactionCountStub.clear() + }) + + describe('if feature flag enabled', () => { + beforeEach(() => { + addExperimentalFeatures([ExperimentalFeature.INTERACTION_TO_NEXT_PAINT]) + }) + + afterEach(() => { + resetExperimentalFeatures() + }) + + it('should return undefined when there are no interactions', () => { + setupBuilder.build() + expect(getInteractionToNextPaint()).toEqual(undefined) + }) + + it('should ignore entries without interactionId', () => { + const { lifeCycle } = setupBuilder.build() + newInteraction(lifeCycle, { + interactionId: undefined, + }) + expect(getInteractionToNextPaint()).toEqual(undefined) + }) + + it('should return the p98 worst interaction', () => { + const { lifeCycle } = setupBuilder.build() + for (let index = 1; index <= 100; index++) { + newInteraction(lifeCycle, { + duration: index as Duration, + interactionId: index, + }) + } + expect(getInteractionToNextPaint()).toEqual(98 as Duration) + }) + + it('should return 0 if no interaction is tracked (because the duration is below 40ms)', () => { + setupBuilder.build() + interactionCountStub.setInteractionCount(1 as Duration) // assumes an interaction happened but no PERFORMANCE_ENTRIES_COLLECTED have been triggered + expect(getInteractionToNextPaint()).toEqual(0 as Duration) + }) + }) + + describe('if feature flag disabled', () => { + it('should return undefined', () => { + const { lifeCycle } = setupBuilder.build() + newInteraction(lifeCycle, { + interactionId: 1, + }) + expect(getInteractionToNextPaint()).toEqual(undefined) + }) + }) +}) + +describe('trackViewInteractionCount', () => { + let interactionCountStub: ReturnType + + beforeEach(() => { + interactionCountStub = subInteractionCount() + interactionCountStub.setInteractionCount(5 as Duration) + }) + afterEach(() => { + interactionCountStub.clear() + }) + + it('should count the interaction happening since the time origin when view loading type is initial_load', () => { + const { getViewInteractionCount } = trackViewInteractionCount(ViewLoadingType.INITIAL_LOAD) + + expect(getViewInteractionCount()).toEqual(5) + }) + + it('should count the interaction from the moment the function is called when view loading type is route_change', () => { + const { getViewInteractionCount } = trackViewInteractionCount(ViewLoadingType.ROUTE_CHANGE) + + expect(getViewInteractionCount()).toEqual(0) + }) +}) + +function subInteractionCount() { + let interactionCount = 0 + const originalInteractionCount = Object.getOwnPropertyDescriptor(window.performance, 'interactionCount') + Object.defineProperty(window.performance, 'interactionCount', { get: () => interactionCount, configurable: true }) + + return { + setInteractionCount: (newInteractionCount: Duration) => { + interactionCount = newInteractionCount + }, + incrementInteractionCount() { + interactionCount++ + }, + clear: () => { + if (originalInteractionCount) { + Object.defineProperty(window.performance, 'interactionCount', originalInteractionCount) + } else { + delete (window as BrowserWindow).performance.interactionCount + } + }, + } +} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.ts new file mode 100644 index 0000000000..c2ed9efdb6 --- /dev/null +++ b/packages/rum-core/src/domain/rumEventsCollection/view/trackInteractionToNextPaint.ts @@ -0,0 +1,114 @@ +import { type Duration, noop, isExperimentalFeatureEnabled, ExperimentalFeature } from '@datadog/browser-core' +import { + supportPerformanceTimingEvent, + type RumEventTiming, + type RumFirstInputTiming, +} 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. +const MAX_INTERACTION_ENTRIES = 10 + +/** + * Track the interaction to next paint (INP). + * To avoid outliers, return the p98 worst interaction of the view. + * Documentation: https://web.dev/inp/ + * Reference implementation: https://github.com/GoogleChrome/web-vitals/blob/main/src/onINP.ts + */ +export function trackInteractionToNextPaint(viewLoadingType: ViewLoadingType, lifeCycle: LifeCycle) { + if ( + !isInteractionToNextPaintSupported() || + !isExperimentalFeatureEnabled(ExperimentalFeature.INTERACTION_TO_NEXT_PAINT) + ) { + return { + getInteractionToNextPaint: () => undefined, + stop: noop, + } + } + + // List of longest interactions on the view by duration. + const longestInteractions: Array = [] + + const { getViewInteractionCount } = trackViewInteractionCount(viewLoadingType) + let maxInpDuration = -1 as Duration + + const { unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, (entries) => { + for (const entry of entries) { + if (entry.entryType === 'event' && entry.interactionId) { + processEntry(entry) + } + } + + const inp = estimateP98LongestInteraction(getViewInteractionCount) + if (inp && inp.duration > maxInpDuration) { + maxInpDuration = inp.duration + } + }) + + /** + * Process the performance entry: + * - if its duration is long enough, add the performance entry to the list of worst interactions + * - if an entry with the same interaction id exists and but its duration is lower than the new one, then replace it in the list of worst interactions + */ + function processEntry(entry: RumEventTiming | RumFirstInputTiming) { + const interactionIndex = longestInteractions.findIndex( + (interaction) => entry.interactionId === interaction.interactionId + ) + + const minLongestInteraction = longestInteractions[longestInteractions.length - 1] + + if (interactionIndex !== -1) { + if (entry.duration > longestInteractions[interactionIndex].duration) { + longestInteractions[interactionIndex] = entry + sortAndTrimLongestInteractions() + } + } else if ( + longestInteractions.length < MAX_INTERACTION_ENTRIES || + entry.duration > minLongestInteraction.duration + ) { + longestInteractions.push(entry) + sortAndTrimLongestInteractions() + } + } + + /** + * Compute the p98 longest interaction. + * For better performance the computation is based on 10 longest interactions and the interaction count of the current view. + */ + function estimateP98LongestInteraction(getViewInteractionCount: () => number) { + const interactionIndex = Math.min(longestInteractions.length - 1, Math.floor(getViewInteractionCount() / 50)) + return longestInteractions[interactionIndex] + } + + function sortAndTrimLongestInteractions() { + longestInteractions.sort((a, b) => b.duration - a.duration).splice(MAX_INTERACTION_ENTRIES) + } + + return { + getInteractionToNextPaint: () => { + // If the interaction count shows there were interactions but + // none were captured by the PerformanceObserver because of the threshold, report a latency of 0. + if (maxInpDuration === -1 && getViewInteractionCount()) { + return 0 as Duration + } else if (maxInpDuration >= 0) { + return maxInpDuration + } + }, + stop, + } +} + +export function trackViewInteractionCount(viewLoadingType: ViewLoadingType) { + initInteractionCountPolyfill() + const previousInteractionCount = viewLoadingType === ViewLoadingType.INITIAL_LOAD ? 0 : getInteractionCount() + return { + getViewInteractionCount: () => getInteractionCount()! - previousInteractionCount, + stop: noop, + } +} + +export function isInteractionToNextPaintSupported() { + return supportPerformanceTimingEvent('event') && 'interactionId' in PerformanceEventTiming.prototype +} diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackViewMetrics.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackViewMetrics.ts index de2d408dab..b8ae4b4d03 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackViewMetrics.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/trackViewMetrics.ts @@ -21,6 +21,7 @@ import { waitPageActivityEnd } from '../../waitPageActivityEnd' import { getScrollY } from '../../../browser/scroll' import { getViewportDimension } from '../../../browser/viewportObservable' +import { trackInteractionToNextPaint } from './trackInteractionToNextPaint' export interface ScrollMetrics { maxDepth: number @@ -35,6 +36,7 @@ export const THROTTLE_SCROLL_DURATION = ONE_SECOND export interface ViewMetrics { loadingTime?: Duration cumulativeLayoutShift?: number + interactionToNextPaint?: Duration } export function trackViewMetrics( @@ -93,14 +95,20 @@ export function trackViewMetrics( stopCLSTracking = noop } + const { stop: stopINPTracking, getInteractionToNextPaint } = trackInteractionToNextPaint(loadingType, lifeCycle) + return { stop: () => { stopLoadingTimeTracking() stopCLSTracking() stopScrollMetricsTracking() + stopINPTracking() }, setLoadEvent, - viewMetrics, + getViewMetrics: () => { + viewMetrics.interactionToNextPaint = getInteractionToNextPaint() + return viewMetrics + }, getScrollMetrics: () => scrollMetrics, } } diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts index 5e28350917..3cb8c322eb 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/trackViews.ts @@ -49,6 +49,7 @@ export interface ViewEvent { loadingType: ViewLoadingType cumulativeLayoutShift?: number scrollMetrics?: ScrollMetrics + interactionToNextPaint?: Duration } export interface ViewCreatedEvent { @@ -186,7 +187,7 @@ function newView( const { setLoadEvent, stop: stopViewMetricsTracking, - viewMetrics, + getViewMetrics, getScrollMetrics, } = trackViewMetrics(lifeCycle, domMutationObservable, configuration, scheduleViewUpdate, loadingType, startClocks) @@ -232,7 +233,7 @@ function newView( eventCounts, scrollMetrics: getScrollMetrics(), }, - viewMetrics + getViewMetrics() ) ) } 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 998c22d7ec..7222ede40e 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.spec.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.spec.ts @@ -12,6 +12,7 @@ import { startViewCollection } from './viewCollection' const VIEW: ViewEvent = { cumulativeLayoutShift: 1, + interactionToNextPaint: 10 as Duration, customTimings: { bar: 20 as Duration, foo: 10 as Duration, @@ -131,6 +132,7 @@ describe('viewCollection', () => { first_contentful_paint: (10 * 1e6) as ServerDuration, first_input_delay: (12 * 1e6) as ServerDuration, first_input_time: (10 * 1e6) as ServerDuration, + interaction_to_next_paint: (10 * 1e6) as ServerDuration, is_active: false, name: undefined, largest_contentful_paint: (10 * 1e6) as ServerDuration, diff --git a/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.ts b/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.ts index 3ef63f2456..870ede2cf9 100644 --- a/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.ts +++ b/packages/rum-core/src/domain/rumEventsCollection/view/viewCollection.ts @@ -87,6 +87,7 @@ function processViewUpdate( 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), is_active: view.isActive, name: view.name, largest_contentful_paint: toServerDuration(view.timings.largestContentfulPaint), diff --git a/packages/rum-core/src/rawRumEvent.types.ts b/packages/rum-core/src/rawRumEvent.types.ts index 81951e31d9..ff6ba3856b 100644 --- a/packages/rum-core/src/rawRumEvent.types.ts +++ b/packages/rum-core/src/rawRumEvent.types.ts @@ -83,6 +83,7 @@ export interface RawRumViewEvent { first_contentful_paint?: ServerDuration first_input_delay?: ServerDuration first_input_time?: ServerDuration + interaction_to_next_paint?: ServerDuration cumulative_layout_shift?: number custom_timings?: { [key: string]: ServerDuration