Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ [RUMF-776] implement First Input Delay #626

Merged
merged 2 commits into from
Nov 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/tools/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ export enum DOM_EVENT {
VISIBILITY_CHANGE = 'visibilitychange',
DOM_CONTENT_LOADED = 'DOMContentLoaded',
POINTER_DOWN = 'pointerdown',
POINTER_UP = 'pointerup',
POINTER_CANCEL = 'pointercancel',
HASH_CHANGE = 'hashchange',
PAGE_HIDE = 'pagehide',
MOUSE_DOWN = 'mousedown',
}

export enum ResourceType {
Expand Down
106 changes: 99 additions & 7 deletions packages/rum/src/browser/performanceCollection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { addEventListener, Configuration, DOM_EVENT, getRelativeTime, isNumber, monitor } from '@datadog/browser-core'
import {
addEventListener,
addEventListeners,
Configuration,
DOM_EVENT,
getRelativeTime,
isNumber,
monitor,
} from '@datadog/browser-core'
import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle'
import { FAKE_INITIAL_DOCUMENT, isAllowedRequestUrl } from '../domain/rumEventsCollection/resource/resourceUtils'

Expand Down Expand Up @@ -54,22 +62,29 @@ export interface RumLargestContentfulPaintTiming {
startTime: number
}

export interface RumFirstInputTiming {
entryType: 'first-input'
startTime: number
processingStart: number
}

export type RumPerformanceEntry =
| RumPerformanceResourceTiming
| RumPerformanceLongTaskTiming
| RumPerformancePaintTiming
| RumPerformanceNavigationTiming
| RumLargestContentfulPaintTiming
| RumFirstInputTiming

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

function supportPerformanceNavigationTimingEvent() {
function supportPerformanceTimingEvent(entryType: string) {
return (
(window as BrowserWindow).PerformanceObserver &&
PerformanceObserver.supportedEntryTypes !== undefined &&
PerformanceObserver.supportedEntryTypes.includes('navigation')
PerformanceObserver.supportedEntryTypes.includes(entryType)
)
}

Expand All @@ -85,7 +100,7 @@ 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']
const entryTypes = ['resource', 'navigation', 'longtask', 'paint', 'largest-contentful-paint', 'first-input']

observer.observe({ entryTypes })

Expand All @@ -96,11 +111,16 @@ export function startPerformanceCollection(lifeCycle: LifeCycle, configuration:
})
}
}
if (!supportPerformanceNavigationTimingEvent()) {
if (!supportPerformanceTimingEvent('navigation')) {
retrieveNavigationTiming((timing) => {
handleRumPerformanceEntry(lifeCycle, configuration, timing)
})
}
if (!supportPerformanceTimingEvent('first-input')) {
retrieveFirstInputTiming((timing) => {
handleRumPerformanceEntry(lifeCycle, configuration, timing)
})
}
}

export function retrieveInitialDocumentResourceTiming(callback: (timing: RumPerformanceResourceTiming) => void) {
Expand All @@ -112,7 +132,7 @@ export function retrieveInitialDocumentResourceTiming(callback: (timing: RumPerf
initiatorType: FAKE_INITIAL_DOCUMENT,
traceId: getDocumentTraceId(document),
}
if (supportPerformanceNavigationTimingEvent() && performance.getEntriesByType('navigation').length > 0) {
if (supportPerformanceTimingEvent('navigation') && performance.getEntriesByType('navigation').length > 0) {
const navigationEntry = performance.getEntriesByType('navigation')[0]
timing = { ...navigationEntry.toJSON(), ...forcedAttributes }
} else {
Expand Down Expand Up @@ -144,6 +164,77 @@ function retrieveNavigationTiming(callback: (timing: RumPerformanceNavigationTim
})
}

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

const { stop: removeEventListeners } = addEventListeners(
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: 'first-input',
processingStart: performance.now(),
startTime: evt.timeStamp,
}

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

/**
* Pointer events are a special case, because they can trigger main or compositor thread behavior.
* We differenciate 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(timing: RumFirstInputTiming) {
addEventListeners(
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 < Date.now() - startTimeStamp) {
callback(timing)
}
}
}
}

function runOnReadyState(expectedReadyState: 'complete' | 'interactive', callback: () => void) {
if (document.readyState === expectedReadyState || document.readyState === 'complete') {
callback()
Expand Down Expand Up @@ -175,7 +266,8 @@ function handlePerformanceEntries(lifeCycle: LifeCycle, configuration: Configura
entry.entryType === 'navigation' ||
entry.entryType === 'paint' ||
entry.entryType === 'longtask' ||
entry.entryType === 'largest-contentful-paint'
entry.entryType === 'largest-contentful-paint' ||
entry.entryType === 'first-input'
) {
handleRumPerformanceEntry(lifeCycle, configuration, entry as RumPerformanceEntry)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createNewEvent, DOM_EVENT, restorePageVisibility, setPageVisibility } from '@datadog/browser-core'
import { setup, TestSetupBuilder } from '../../../../test/specHelper'
import {
RumFirstInputTiming,
RumLargestContentfulPaintTiming,
RumPerformanceNavigationTiming,
RumPerformancePaintTiming,
Expand All @@ -10,6 +11,7 @@ import { resetFirstHidden } from './trackFirstHidden'
import {
Timings,
trackFirstContentfulPaint,
trackFirstInputDelay,
trackLargestContentfulPaint,
trackNavigationTimings,
trackTimings,
Expand All @@ -34,6 +36,12 @@ const FAKE_LARGEST_CONTENTFUL_PAINT_ENTRY: RumLargestContentfulPaintTiming = {
startTime: 789,
}

const FAKE_FIRST_INPUT_ENTRY: RumFirstInputTiming = {
entryType: 'first-input',
processingStart: 1100,
startTime: 1000,
}

describe('trackTimings', () => {
let setupBuilder: TestSetupBuilder
let timingsCallback: jasmine.Spy<(value: Partial<Timings>) => void>
Expand All @@ -54,13 +62,15 @@ describe('trackTimings', () => {

lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_NAVIGATION_ENTRY)
lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_PAINT_ENTRY)
lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_FIRST_INPUT_ENTRY)

expect(timingsCallback).toHaveBeenCalledTimes(2)
expect(timingsCallback).toHaveBeenCalledTimes(3)
expect(timingsCallback.calls.mostRecent().args[0]).toEqual({
domComplete: 456,
domContentLoaded: 345,
domInteractive: 234,
firstContentfulPaint: 123,
firstInputDelay: 100,
loadEventEnd: 567,
})
})
Expand Down Expand Up @@ -177,3 +187,39 @@ describe('largestContentfulPaint', () => {
expect(lcpCallback).not.toHaveBeenCalled()
})
})

describe('firstInputDelay', () => {
let setupBuilder: TestSetupBuilder
let fidCallback: jasmine.Spy<(value: number) => void>

beforeEach(() => {
fidCallback = jasmine.createSpy()
setupBuilder = setup().beforeBuild(({ lifeCycle }) => {
return trackFirstInputDelay(lifeCycle, fidCallback)
})
resetFirstHidden()
})

afterEach(() => {
setupBuilder.cleanup()
restorePageVisibility()
resetFirstHidden()
})

it('should provide the first input delay', () => {
const { lifeCycle } = setupBuilder.build()

lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_FIRST_INPUT_ENTRY)
expect(fidCallback).toHaveBeenCalledTimes(1)
expect(fidCallback).toHaveBeenCalledWith(100)
})

it('should not be present if the page is hidden', () => {
setPageVisibility('hidden')
const { lifeCycle } = setupBuilder.build()

lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, FAKE_FIRST_INPUT_ENTRY)

expect(fidCallback).not.toHaveBeenCalled()
})
})
27 changes: 27 additions & 0 deletions packages/rum/src/domain/rumEventsCollection/view/trackTimings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface Timings {
domComplete?: number
loadEventEnd?: number
largestContentfulPaint?: number
firstInputDelay?: number
}

export function trackTimings(lifeCycle: LifeCycle, callback: (timings: Timings) => void) {
Expand All @@ -27,12 +28,18 @@ export function trackTimings(lifeCycle: LifeCycle, callback: (timings: Timings)
largestContentfulPaint,
})
})
const { stop: stopFIDTracking } = trackFirstInputDelay(lifeCycle, (firstInputDelay) => {
setTimings({
firstInputDelay,
})
})

return {
stop() {
stopNavigationTracking()
stopFCPTracking()
stopLCPTracking()
stopFIDTracking()
},
}
}
Expand Down Expand Up @@ -112,3 +119,23 @@ export function trackLargestContentfulPaint(
},
}
}

/**
* Track the first input delay (FID) occuring during the initial View. This yields at most one
* value.
* Documentation: https://web.dev/fid/
* Reference implementation: https://github.com/GoogleChrome/web-vitals/blob/master/src/getFID.ts
*/
export function trackFirstInputDelay(lifeCycle: LifeCycle, callback: (value: number) => void) {
const firstHidden = trackFirstHidden()

const { unsubscribe: stop } = lifeCycle.subscribe(LifeCycleEventType.PERFORMANCE_ENTRY_COLLECTED, (entry) => {
if (entry.entryType === 'first-input' && entry.startTime < firstHidden.timeStamp) {
callback(entry.processingStart - entry.startTime)
}
})

return {
stop,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ describe('viewCollection V2', () => {
domContentLoaded: 10,
domInteractive: 10,
firstContentfulPaint: 10,
firstInputDelay: 12,
largestContentfulPaint: 10,
loadEventEnd: 10,
},
Expand All @@ -142,6 +143,7 @@ describe('viewCollection V2', () => {
count: 10,
},
firstContentfulPaint: 10 * 1e6,
firstInputDelay: 12 * 1e6,
largestContentfulPaint: 10 * 1e6,
loadEventEnd: 10 * 1e6,
loadingTime: 20 * 1e6,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function processViewUpdateV2(view: View) {
count: view.eventCounts.errorCount,
},
firstContentfulPaint: msToNs(view.timings.firstContentfulPaint),
firstInputDelay: msToNs(view.timings.firstInputDelay),
largestContentfulPaint: msToNs(view.timings.largestContentfulPaint),
loadEventEnd: msToNs(view.timings.loadEventEnd),
loadingTime: msToNs(view.loadingTime),
Expand Down
1 change: 1 addition & 0 deletions packages/rum/src/typesV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export interface RumViewEventV2 {
view: {
loadingType: ViewLoadingType
firstContentfulPaint?: number
firstInputDelay?: number
largestContentfulPaint?: number
domInteractive?: number
domContentLoaded?: number
Expand Down
1 change: 1 addition & 0 deletions test/e2e/lib/types/serverEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export interface ServerRumViewEvent extends ServerRumEvent {
dom_content_loaded: number
dom_interactive: number
load_event_end: number
first_input_delay?: number
}
}

Expand Down
20 changes: 20 additions & 0 deletions test/e2e/scenario/rum/views.scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ describe('rum views', () => {
expect(viewEvent.view.load_event_end).toBeGreaterThan(0)
})

// When run via WebDriver, Safari 12 and 13 (at least) have an issue with `event.timeStamp`,
// so the 'first-input' polyfill is ignoring it and doesn't send a performance entry.
// See https://bugs.webkit.org/show_bug.cgi?id=211101
if (browser.capabilities.browserName !== 'Safari 12.0') {
createTest('send performance first input delay')
.withRum()
.withBody(
html`
<button>Hop</button>
`
)
.run(async ({ events }) => {
await (await $('button')).click()
await flushEvents()
const viewEvent = events.rumViews[0]
expect(viewEvent).toBeDefined()
expect(viewEvent.view.first_input_delay).toBeGreaterThanOrEqual(0)
})
}

createTest('create a new View when the session is renewed')
.withRum()
.run(async ({ events }) => {
Expand Down