Skip to content

Commit

Permalink
Implement INP
Browse files Browse the repository at this point in the history
  • Loading branch information
amortemousque committed Jul 27, 2023
1 parent d504bde commit 73c2fdc
Show file tree
Hide file tree
Showing 11 changed files with 359 additions and 7 deletions.
1 change: 1 addition & 0 deletions packages/core/src/tools/experimentalFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExperimentalFeature> = new Set()
Expand Down
37 changes: 33 additions & 4 deletions packages/rum-core/src/browser/performanceCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -90,6 +110,7 @@ export type RumPerformanceEntry =
| RumPerformanceNavigationTiming
| RumLargestContentfulPaintTiming
| RumFirstInputTiming
| RumEventTiming
| RumLayoutShiftTiming

function supportPerformanceObject() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -294,6 +295,7 @@ describe('firstInputTimings', () => {
entryType: 'first-input' as const,
processingStart: 900 as RelativeTime,
startTime: 1000 as RelativeTime,
duration: 10 as Duration,
},
])

Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof subInteractionCount>
let getInteractionToNextPaint: () => Duration | undefined

function newInteraction(
lifeCycle: LifeCycle,
{ interactionId, duration = 40 as Duration, entryType = 'event' }: Partial<RumEventTiming | RumFirstInputTiming>
) {
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<typeof subInteractionCount>

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
}
},
}
}
Loading

0 comments on commit 73c2fdc

Please sign in to comment.