Skip to content

Commit

Permalink
Use performanceObserver for first input and event entries
Browse files Browse the repository at this point in the history
  • Loading branch information
amortemousque committed Sep 16, 2024
1 parent 301a60f commit ccf94b7
Show file tree
Hide file tree
Showing 11 changed files with 222 additions and 190 deletions.
87 changes: 87 additions & 0 deletions packages/rum-core/src/browser/firstInputPolyfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Duration, RelativeTime } from '@datadog/browser-core'
import { addEventListeners, dateNow, DOM_EVENT, relativeNow } from '@datadog/browser-core'
import type { RumConfiguration } from '../domain/configuration'

/**
* first-input timing entry polyfill based on
* https://github.com/GoogleChrome/web-vitals/blob/master/src/lib/polyfills/firstInputPolyfill.ts
*/
export function retrieveFirstInputTiming(
configuration: RumConfiguration,
callback: (timing: PerformanceEventTiming) => void
) {
const startTimeStamp = dateNow()
let timingSent = false

const { stop: removeEventListeners } = addEventListeners(
configuration,
window,
[DOM_EVENT.CLICK, DOM_EVENT.MOUSE_DOWN, DOM_EVENT.KEY_DOWN, DOM_EVENT.TOUCH_START, DOM_EVENT.POINTER_DOWN],
(evt) => {
// Only count cancelable events, which should trigger behavior important to the user.
if (!evt.cancelable) {
return
}

// This timing will be used to compute the "first Input delay", which is the delta between
// when the system received the event (e.g. evt.timeStamp) and when it could run the callback
// (e.g. performance.now()).
const timing: PerformanceEventTiming = {
entryType: 'first-input',
processingStart: relativeNow(),
processingEnd: relativeNow(),
startTime: evt.timeStamp as RelativeTime,
duration: 0 as Duration, // arbitrary value to avoid nullable duration and simplify INP logic
name: '',
cancelable: false,
target: null,
toJSON: () => ({}),
}

if (evt.type === DOM_EVENT.POINTER_DOWN) {
sendTimingIfPointerIsNotCancelled(configuration, timing)
} else {
sendTiming(timing)
}
},
{ passive: true, capture: true }
)

return { stop: removeEventListeners }

/**
* Pointer events are a special case, because they can trigger main or compositor thread behavior.
* We differentiate these cases based on whether or not we see a pointercancel event, which are
* fired when we scroll. If we're scrolling we don't need to report input delay since FID excludes
* scrolling and pinch/zooming.
*/
function sendTimingIfPointerIsNotCancelled(configuration: RumConfiguration, timing: PerformanceEventTiming) {
addEventListeners(
configuration,
window,
[DOM_EVENT.POINTER_UP, DOM_EVENT.POINTER_CANCEL],
(event) => {
if (event.type === DOM_EVENT.POINTER_UP) {
sendTiming(timing)
}
},
{ once: true }
)
}

function sendTiming(timing: PerformanceEventTiming) {
if (!timingSent) {
timingSent = true
removeEventListeners()
// In some cases the recorded delay is clearly wrong, e.g. it's negative or it's larger than
// the time between now and when the page was loaded.
// - https://github.com/GoogleChromeLabs/first-input-delay/issues/4
// - https://github.com/GoogleChromeLabs/first-input-delay/issues/6
// - https://github.com/GoogleChromeLabs/first-input-delay/issues/7
const delay = timing.processingStart - timing.startTime
if (delay >= 0 && delay < dateNow() - startTimeStamp) {
callback(timing)
}
}
}
}
9 changes: 6 additions & 3 deletions packages/rum-core/src/browser/performanceCollection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ describe('startPerformanceCollection', () => {
RumPerformanceEntryType.LONG_TASK,
RumPerformanceEntryType.PAINT,
RumPerformanceEntryType.LARGEST_CONTENTFUL_PAINT,
RumPerformanceEntryType.FIRST_INPUT,
RumPerformanceEntryType.LAYOUT_SHIFT,
RumPerformanceEntryType.EVENT,
].forEach((entryType) => {
it(`should notify ${entryType}`, () => {
const { notifyPerformanceEntries } = mockPerformanceObserver()
Expand All @@ -36,7 +34,12 @@ describe('startPerformanceCollection', () => {
expect(entryCollectedCallback).toHaveBeenCalledWith([jasmine.objectContaining({ entryType })])
})
})
;[(RumPerformanceEntryType.NAVIGATION, RumPerformanceEntryType.RESOURCE)].forEach((entryType) => {
;[
RumPerformanceEntryType.NAVIGATION,
RumPerformanceEntryType.RESOURCE,
RumPerformanceEntryType.FIRST_INPUT,
RumPerformanceEntryType.EVENT,
].forEach((entryType) => {
it(`should not notify ${entryType} timings`, () => {
const { notifyPerformanceEntries } = mockPerformanceObserver()
setupStartPerformanceCollection()
Expand Down
114 changes: 7 additions & 107 deletions packages/rum-core/src/browser/performanceCollection.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,4 @@
import type { Duration, RelativeTime } from '@datadog/browser-core'
import {
dateNow,
addEventListeners,
DOM_EVENT,
monitor,
setTimeout,
relativeNow,
addEventListener,
objectHasValue,
} from '@datadog/browser-core'

import { monitor, setTimeout, addEventListener, objectHasValue } from '@datadog/browser-core'
import type { RumConfiguration } from '../domain/configuration'
import type { LifeCycle } from '../domain/lifeCycle'
import { LifeCycleEventType } from '../domain/lifeCycle'
Expand All @@ -19,13 +8,16 @@ import type {
RumPerformanceEntry,
RumPerformanceResourceTiming,
} from './performanceObservable'
import { RumPerformanceEntryType, supportPerformanceTimingEvent } from './performanceObservable'
import { RumPerformanceEntryType } from './performanceObservable'

function supportPerformanceObject() {
return window.performance !== undefined && 'getEntries' in performance
}

export type CollectionRumPerformanceEntry = Exclude<RumPerformanceEntry, RumPerformanceResourceTiming>
export type CollectionRumPerformanceEntry = Exclude<
RumPerformanceEntry,
RumPerformanceResourceTiming | RumFirstInputTiming
>

export function startPerformanceCollection(lifeCycle: LifeCycle, configuration: RumConfiguration) {
const cleanupTasks: Array<() => void> = []
Expand All @@ -42,12 +34,7 @@ export function startPerformanceCollection(lifeCycle: LifeCycle, configuration:
handleRumPerformanceEntries(lifeCycle, entries.getEntries())
)
const mainEntries = [RumPerformanceEntryType.LONG_TASK, RumPerformanceEntryType.PAINT]
const experimentalEntries = [
RumPerformanceEntryType.LARGEST_CONTENTFUL_PAINT,
RumPerformanceEntryType.FIRST_INPUT,
RumPerformanceEntryType.LAYOUT_SHIFT,
RumPerformanceEntryType.EVENT,
]
const experimentalEntries = [RumPerformanceEntryType.LARGEST_CONTENTFUL_PAINT, RumPerformanceEntryType.LAYOUT_SHIFT]

try {
// Experimental entries are not retrieved by performance.getEntries()
Expand All @@ -58,9 +45,6 @@ export function startPerformanceCollection(lifeCycle: LifeCycle, configuration:
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,
})
cleanupTasks.push(() => observer.disconnect())
})
Expand Down Expand Up @@ -98,97 +82,13 @@ export function startPerformanceCollection(lifeCycle: LifeCycle, configuration:
}
}

if (!supportPerformanceTimingEvent(RumPerformanceEntryType.FIRST_INPUT)) {
const { stop: stopFirstInputTiming } = retrieveFirstInputTiming(configuration, (timing) => {
handleRumPerformanceEntries(lifeCycle, [timing])
})
cleanupTasks.push(stopFirstInputTiming)
}
return {
stop: () => {
cleanupTasks.forEach((task) => task())
},
}
}

/**
* first-input timing entry polyfill based on
* https://github.com/GoogleChrome/web-vitals/blob/master/src/lib/polyfills/firstInputPolyfill.ts
*/
function retrieveFirstInputTiming(configuration: RumConfiguration, callback: (timing: RumFirstInputTiming) => void) {
const startTimeStamp = dateNow()
let timingSent = false

const { stop: removeEventListeners } = addEventListeners(
configuration,
window,
[DOM_EVENT.CLICK, DOM_EVENT.MOUSE_DOWN, DOM_EVENT.KEY_DOWN, DOM_EVENT.TOUCH_START, DOM_EVENT.POINTER_DOWN],
(evt) => {
// Only count cancelable events, which should trigger behavior important to the user.
if (!evt.cancelable) {
return
}

// This timing will be used to compute the "first Input delay", which is the delta between
// when the system received the event (e.g. evt.timeStamp) and when it could run the callback
// (e.g. performance.now()).
const timing: RumFirstInputTiming = {
entryType: RumPerformanceEntryType.FIRST_INPUT,
processingStart: relativeNow(),
processingEnd: relativeNow(),
startTime: evt.timeStamp as RelativeTime,
duration: 0 as Duration, // arbitrary value to avoid nullable duration and simplify INP logic
name: '',
}

if (evt.type === DOM_EVENT.POINTER_DOWN) {
sendTimingIfPointerIsNotCancelled(configuration, timing)
} else {
sendTiming(timing)
}
},
{ passive: true, capture: true }
)

return { stop: removeEventListeners }

/**
* Pointer events are a special case, because they can trigger main or compositor thread behavior.
* We differentiate these cases based on whether or not we see a pointercancel event, which are
* fired when we scroll. If we're scrolling we don't need to report input delay since FID excludes
* scrolling and pinch/zooming.
*/
function sendTimingIfPointerIsNotCancelled(configuration: RumConfiguration, timing: RumFirstInputTiming) {
addEventListeners(
configuration,
window,
[DOM_EVENT.POINTER_UP, DOM_EVENT.POINTER_CANCEL],
(event) => {
if (event.type === DOM_EVENT.POINTER_UP) {
sendTiming(timing)
}
},
{ once: true }
)
}

function sendTiming(timing: RumFirstInputTiming) {
if (!timingSent) {
timingSent = true
removeEventListeners()
// In some cases the recorded delay is clearly wrong, e.g. it's negative or it's larger than
// the time between now and when the page was loaded.
// - https://github.com/GoogleChromeLabs/first-input-delay/issues/4
// - https://github.com/GoogleChromeLabs/first-input-delay/issues/6
// - https://github.com/GoogleChromeLabs/first-input-delay/issues/7
const delay = timing.processingStart - timing.startTime
if (delay >= 0 && delay < dateNow() - startTimeStamp) {
callback(timing)
}
}
}
}

function handleRumPerformanceEntries(
lifeCycle: LifeCycle,
entries: Array<PerformanceEntry | CollectionRumPerformanceEntry>
Expand Down
16 changes: 15 additions & 1 deletion packages/rum-core/src/browser/performanceObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Duration, RelativeTime, TimeoutId } from '@datadog/browser-core'
import { addEventListener, Observable, setTimeout, clearTimeout, monitor, includes } from '@datadog/browser-core'
import type { RumConfiguration } from '../domain/configuration'
import { hasValidResourceEntryDuration, isAllowedRequestUrl } from '../domain/resource/resourceUtils'
import { retrieveFirstInputTiming } from './firstInputPolyfill'

type RumPerformanceObserverConstructor = new (callback: PerformanceObserverCallback) => RumPerformanceObserver

Expand All @@ -11,7 +12,7 @@ export interface BrowserWindow extends Window {
}

export interface RumPerformanceObserver extends PerformanceObserver {
observe(options?: PerformanceObserverInit & { durationThreshold: number }): void
observe(options?: PerformanceObserverInit & { durationThreshold?: number }): void
}

// We want to use a real enum (i.e. not a const enum) here, to be able to check whether an arbitrary
Expand Down Expand Up @@ -243,8 +244,21 @@ export function createPerformanceObservable<T extends RumPerformanceEntryType>(

manageResourceTimingBufferFull(configuration)

let stopFirstInputTiming: (() => void) | undefined
if (
!supportPerformanceTimingEvent(RumPerformanceEntryType.FIRST_INPUT) &&
options.type === RumPerformanceEntryType.FIRST_INPUT
) {
;({ stop: stopFirstInputTiming } = retrieveFirstInputTiming(configuration, (timing) => {
handlePerformanceEntries([timing])
}))
}

return () => {
observer.disconnect()
if (stopFirstInputTiming) {
stopFirstInputTiming()
}
clearTimeout(timeoutId)
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function trackCommonViewMetrics(
stop: stopINPTracking,
getInteractionToNextPaint,
setViewEnd,
} = trackInteractionToNextPaint(configuration, viewStart.relative, loadingType, lifeCycle)
} = trackInteractionToNextPaint(configuration, viewStart.relative, loadingType)

return {
stop: () => {
Expand Down
Loading

0 comments on commit ccf94b7

Please sign in to comment.