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-775] implement Largest Contentful Paint #624

Merged
merged 5 commits into from
Nov 20, 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
14 changes: 11 additions & 3 deletions packages/core/src/tools/specHelper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Configuration } from '../domain/configuration'
import { noop } from './utils'
import { noop, objectEntries } from './utils'

export const SPEC_ENDPOINTS: Partial<Configuration> = {
internalMonitoringEndpoint: 'https://monitoring-intake.com/v1/input/abcde?foo=bar',
Expand Down Expand Up @@ -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
}

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/tools/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Expand Down Expand Up @@ -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]])
}

Expand Down
11 changes: 9 additions & 2 deletions packages/rum/src/browser/performanceCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 })

Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand All @@ -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)
})
Expand All @@ -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)
}
})
100 changes: 81 additions & 19 deletions packages/rum/src/domain/rumEventsCollection/view/trackTimings.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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<Timings>) => void>
let timingsCallback: jasmine.Spy<(value: Partial<Timings>) => void>

beforeEach(() => {
spy = jasmine.createSpy()
timingsCallback = jasmine.createSpy()
setupBuilder = setup().beforeBuild(({ lifeCycle }) => {
return trackTimings(lifeCycle, spy)
return trackTimings(lifeCycle, timingsCallback)
})
})

Expand All @@ -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,
Expand All @@ -53,12 +68,12 @@ describe('trackTimings', () => {

describe('trackNavigationTimings', () => {
let setupBuilder: TestSetupBuilder
let spy: jasmine.Spy<(value: Partial<Timings>) => void>
let navigationTimingsCallback: jasmine.Spy<(value: Partial<Timings>) => void>

beforeEach(() => {
spy = jasmine.createSpy()
navigationTimingsCallback = jasmine.createSpy()
setupBuilder = setup().beforeBuild(({ lifeCycle }) => {
return trackNavigationTimings(lifeCycle, spy)
return trackNavigationTimings(lifeCycle, navigationTimingsCallback)
})
})

Expand All @@ -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,
Expand All @@ -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()
})
Expand All @@ -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()
})
})
55 changes: 55 additions & 0 deletions packages/rum/src/domain/rumEventsCollection/view/trackTimings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { addEventListeners, DOM_EVENT, EventEmitter } from '@datadog/browser-core'
import { LifeCycle, LifeCycleEventType } from '../../lifeCycle'
import { trackFirstHidden } from './trackFirstHidden'

Expand All @@ -7,6 +8,7 @@ export interface Timings {
domContentLoaded?: number
domComplete?: number
loadEventEnd?: number
largestContentfulPaint?: number
}

export function trackTimings(lifeCycle: LifeCycle, callback: (timings: Timings) => void) {
Expand All @@ -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()
},
}
}
Expand Down Expand Up @@ -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()
},
}
}
Loading