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

⚗️ [RUM-1020] Collect core web vitals target selectors #2418

Merged
merged 21 commits into from
Sep 14, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,12 @@ describe('viewCollection', () => {
first_contentful_paint: (10 * 1e6) as ServerDuration,
first_input_delay: (12 * 1e6) as ServerDuration,
first_input_time: (10 * 1e6) as ServerDuration,
first_input_target_selector: undefined,
interaction_to_next_paint: (10 * 1e6) as ServerDuration,
is_active: false,
name: undefined,
largest_contentful_paint: (10 * 1e6) as ServerDuration,
largest_contentful_paint_target_selector: undefined,
load_event: (10 * 1e6) as ServerDuration,
loading_time: (20 * 1e6) as ServerDuration,
loading_type: ViewLoadingType.INITIAL_LOAD,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function processViewUpdate(
first_contentful_paint: toServerDuration(view.initialViewMetrics.firstContentfulPaint),
first_input_delay: toServerDuration(view.initialViewMetrics.firstInputDelay),
first_input_time: toServerDuration(view.initialViewMetrics.firstInputTime),
first_input_target_selector: view.initialViewMetrics.firstInputTargetSelector,
interaction_to_next_paint: toServerDuration(view.commonViewMetrics.interactionToNextPaint),
is_active: view.isActive,
name: view.name,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,58 @@
import { noop, type Duration, type RelativeTime } from '@datadog/browser-core'
import {
noop,
type Duration,
type RelativeTime,
resetExperimentalFeatures,
ExperimentalFeature,
addExperimentalFeatures,
} from '@datadog/browser-core'
import { restorePageVisibility, setPageVisibility } from '@datadog/browser-core/test'
import type { RumFirstInputTiming } from '../../../../browser/performanceCollection'
import type { TestSetupBuilder } from '../../../../../test'
import { setup } from '../../../../../test'
import type { LifeCycle } from '../../../lifeCycle'
import { LifeCycleEventType } from '../../../lifeCycle'
import type { RumConfiguration } from '../../../configuration'
import { FAKE_FIRST_INPUT_ENTRY } from '../setupViewTest.specHelper'
import { resetFirstHidden } from './trackFirstHidden'
import { trackFirstInputTimings } from './trackFirstInputTimings'

describe('firstInputTimings', () => {
let setupBuilder: TestSetupBuilder
let fitCallback: jasmine.Spy<
({ firstInputDelay, firstInputTime }: { firstInputDelay: number; firstInputTime: number }) => void
({
firstInputDelay,
firstInputTime,
firstInputTargetSelector,
}: {
firstInputDelay: number
firstInputTime: number
firstInputTargetSelector?: string
}) => void
>
let configuration: RumConfiguration
let target: HTMLButtonElement

function newFirstInput(lifeCycle: LifeCycle, overrides?: Partial<RumFirstInputTiming>) {
lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [
{
entryType: 'first-input',
processingStart: 1100 as RelativeTime,
startTime: 1000 as RelativeTime,
duration: 0 as Duration,
target,
...overrides,
},
])
}

beforeEach(() => {
configuration = {} as RumConfiguration
fitCallback = jasmine.createSpy()

target = document.createElement('button')
target.setAttribute('id', 'fid-target-element')
document.body.appendChild(target)
amortemousque marked this conversation as resolved.
Show resolved Hide resolved

setupBuilder = setup().beforeBuild(({ lifeCycle }) =>
trackFirstInputTimings(lifeCycle, configuration, { addWebVitalTelemetryDebug: noop }, fitCallback)
)
Expand All @@ -26,45 +61,64 @@ describe('firstInputTimings', () => {

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

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

lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [
{ ...FAKE_FIRST_INPUT_ENTRY, target: document.createElement('button') },
])
newFirstInput(lifeCycle)

expect(fitCallback).toHaveBeenCalledTimes(1)
expect(fitCallback).toHaveBeenCalledWith({
firstInputDelay: 100,
firstInputTime: 1000,
firstInputTargetSelector: undefined,
})
})

it('should provide the first input target selector if FF enabled', () => {
addExperimentalFeatures([ExperimentalFeature.WEB_VITALS_ATTRIBUTION])
const { lifeCycle } = setupBuilder.build()

newFirstInput(lifeCycle)

expect(fitCallback).toHaveBeenCalledTimes(1)
expect(fitCallback).toHaveBeenCalledWith(
jasmine.objectContaining({
firstInputTargetSelector: '#fid-target-element',
})
)
})

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

lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [FAKE_FIRST_INPUT_ENTRY])
newFirstInput(lifeCycle)

expect(fitCallback).not.toHaveBeenCalled()
})

it('should be adjusted to 0 if the computed value would be negative due to browser timings imprecisions', () => {
const { lifeCycle } = setupBuilder.build()

lifeCycle.notify(LifeCycleEventType.PERFORMANCE_ENTRIES_COLLECTED, [
{
entryType: 'first-input' as const,
processingStart: 900 as RelativeTime,
startTime: 1000 as RelativeTime,
duration: 0 as Duration,
},
])
newFirstInput(lifeCycle, {
entryType: 'first-input' as const,
processingStart: 900 as RelativeTime,
startTime: 1000 as RelativeTime,
duration: 0 as Duration,
})

expect(fitCallback).toHaveBeenCalledTimes(1)
expect(fitCallback).toHaveBeenCalledWith({ firstInputDelay: 0, firstInputTime: 1000 })
expect(fitCallback).toHaveBeenCalledWith(
jasmine.objectContaining({
firstInputDelay: 0,
firstInputTime: 1000,
})
)
})
})
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { Duration, RelativeTime } from '@datadog/browser-core'
import { elapsed, find } from '@datadog/browser-core'
import { elapsed, find, ExperimentalFeature, isExperimentalFeatureEnabled } from '@datadog/browser-core'
import { isElementNode } from '../../../../browser/htmlDomUtils'
import type { RumConfiguration } from '../../../configuration'
import type { LifeCycle } from '../../../lifeCycle'
import { LifeCycleEventType } from '../../../lifeCycle'
import type { RumFirstInputTiming } from '../../../../browser/performanceCollection'
import type { WebVitalTelemetryDebug } from '../startWebVitalTelemetryDebug'
import { getSelectorFromElement } from '../../../getSelectorFromElement'
import { trackFirstHidden } from './trackFirstHidden'

/**
Expand All @@ -20,7 +22,15 @@ export function trackFirstInputTimings(
lifeCycle: LifeCycle,
configuration: RumConfiguration,
webVitalTelemetryDebug: WebVitalTelemetryDebug,
callback: ({ firstInputDelay, firstInputTime }: { firstInputDelay: Duration; firstInputTime: RelativeTime }) => void
callback: ({
firstInputDelay,
firstInputTime,
firstInputTargetSelector,
}: {
firstInputDelay: Duration
firstInputTime: RelativeTime
firstInputTargetSelector?: string
}) => void
) {
const firstHidden = trackFirstHidden(configuration)

Expand All @@ -32,15 +42,25 @@ export function trackFirstInputTimings(
)
if (firstInputEntry) {
const firstInputDelay = elapsed(firstInputEntry.startTime, firstInputEntry.processingStart)
let firstInputTargetSelector

webVitalTelemetryDebug.addWebVitalTelemetryDebug('FID', firstInputEntry.target, firstInputEntry.startTime)
if (
isExperimentalFeatureEnabled(ExperimentalFeature.WEB_VITALS_ATTRIBUTION) &&
firstInputEntry.target &&
isElementNode(firstInputEntry.target)
amortemousque marked this conversation as resolved.
Show resolved Hide resolved
) {
firstInputTargetSelector = getSelectorFromElement(firstInputEntry.target, configuration.actionNameAttribute)
}

callback({
// Ensure firstInputDelay to be positive, see
// https://bugs.chromium.org/p/chromium/issues/detail?id=1185815
firstInputDelay: firstInputDelay >= 0 ? firstInputDelay : (0 as Duration),
firstInputTime: firstInputEntry.startTime,
firstInputTargetSelector,
})

webVitalTelemetryDebug.addWebVitalTelemetryDebug('FID', firstInputEntry.target, firstInputEntry.startTime)
}
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('trackInitialViewMetrics', () => {
firstContentfulPaint: 123 as Duration,
firstInputDelay: 100 as Duration,
firstInputTime: 1000 as Duration,
firstInputTargetSelector: undefined,
loadEvent: 567 as Duration,
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface InitialViewMetrics {
largestContentfulPaintTargetSelector?: string
firstInputDelay?: Duration
firstInputTime?: Duration
firstInputTargetSelector?: string
}

export function trackInitialViewMetrics(
Expand Down Expand Up @@ -67,11 +68,8 @@ export function trackInitialViewMetrics(
lifeCycle,
configuration,
webVitalTelemetryDebug,
({ firstInputDelay, firstInputTime }) => {
setMetrics({
firstInputDelay,
firstInputTime,
})
(firstInputTimings) => {
setMetrics(firstInputTimings)
}
)

Expand Down
1 change: 1 addition & 0 deletions packages/rum-core/src/rawRumEvent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export interface RawRumViewEvent {
first_contentful_paint?: ServerDuration
first_input_delay?: ServerDuration
first_input_time?: ServerDuration
first_input_target_selector?: string
interaction_to_next_paint?: ServerDuration
cumulative_layout_shift?: number
custom_timings?: {
Expand Down