Skip to content

Commit 95ee834

Browse files
authored
✨[RUM-260] Support bfcache restore for web vitals (#3527)
* ✨ Add support for Back-Forward Cache (BFCache) views and related metrics * rum event format * Refactor BFCache metrics tracking * Refactor * ✨ Enhance BFCache metrics tracking with additional tests and comments * ✨ Improve BFCache handling and testing: streamline event creation, enhance metrics tracking, and add unit tests for BFCache restoration. * nit * ✨ TrackViewManually should not track bfCacheView, update clock, update test clarity * ✨ Refactor BFCache metrics tracking * delete stopBfCacheMetricsTracking
1 parent 6b6c2de commit 95ee834

13 files changed

+194
-4
lines changed

packages/core/src/domain/telemetry/telemetryEvent.types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & {
161161
* Whether long tasks are tracked
162162
*/
163163
track_long_task?: boolean
164+
/**
165+
* Whether views loaded from the bfcache are tracked
166+
*/
167+
track_bfcache_views?: boolean
164168
/**
165169
* Whether a secure cross-site session cookie is used (deprecated)
166170
*/
@@ -257,6 +261,10 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & {
257261
* Whether console.error logs, uncaught exceptions and network errors are tracked
258262
*/
259263
forward_errors_to_logs?: boolean
264+
/**
265+
* The number of displays available to the device
266+
*/
267+
number_of_displays?: number
260268
/**
261269
* The console.* tracked
262270
*/
@@ -407,6 +415,10 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & {
407415
* Whether the anonymous users are tracked
408416
*/
409417
track_anonymous_user?: boolean
418+
/**
419+
* Whether a list of allowed origins is used to control SDK execution in browser extension contexts. When enabled, the SDK will check if the current origin matches the allowed origins list before running.
420+
*/
421+
use_allowed_tracking_origins?: boolean
410422
[k: string]: unknown
411423
}
412424
[k: string]: unknown

packages/rum-core/src/domain/configuration/configuration.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,7 @@ describe('serializeRumConfiguration', () => {
535535
trackViewsManually: true,
536536
trackResources: true,
537537
trackLongTasks: true,
538+
trackBfcacheViews: true,
538539
remoteConfigurationId: '123',
539540
plugins: [{ name: 'foo', getConfigurationTelemetry: () => ({ bar: true }) }],
540541
trackFeatureFlagsForEvents: ['vital'],
@@ -578,6 +579,7 @@ describe('serializeRumConfiguration', () => {
578579
enable_privacy_for_action_name: false,
579580
track_resources: true,
580581
track_long_task: true,
582+
track_bfcache_views: true,
581583
use_worker_url: true,
582584
compress_intake_requests: true,
583585
plugins: [{ name: 'foo', bar: true }],

packages/rum-core/src/domain/configuration/configuration.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ export interface RumInitConfiguration extends InitConfiguration {
122122
* Allows you to control RUM views creation. See [Override default RUM view names](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/?tab=npm#override-default-rum-view-names) for further information.
123123
*/
124124
trackViewsManually?: boolean | undefined
125+
/**
126+
* Enable the creation of dedicated views for pages restored from the Back-Forward cache.
127+
* @default false
128+
*/
129+
trackBfcacheViews?: boolean | undefined
125130
/**
126131
* Enables collection of resource events.
127132
* @default true
@@ -175,6 +180,7 @@ export interface RumConfiguration extends Configuration {
175180
trackViewsManually: boolean
176181
trackResources: boolean
177182
trackLongTasks: boolean
183+
trackBfcacheViews: boolean
178184
version?: string
179185
subdomain?: string
180186
customerDataTelemetrySampleRate: number
@@ -245,6 +251,7 @@ export function validateAndBuildRumConfiguration(
245251
trackViewsManually: !!initConfiguration.trackViewsManually,
246252
trackResources: !!(initConfiguration.trackResources ?? true),
247253
trackLongTasks: !!(initConfiguration.trackLongTasks ?? true),
254+
trackBfcacheViews: !!initConfiguration.trackBfcacheViews,
248255
subdomain: initConfiguration.subdomain,
249256
defaultPrivacyLevel: objectHasValue(DefaultPrivacyLevel, initConfiguration.defaultPrivacyLevel)
250257
? initConfiguration.defaultPrivacyLevel
@@ -337,6 +344,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {
337344
track_user_interactions: configuration.trackUserInteractions,
338345
track_resources: configuration.trackResources,
339346
track_long_task: configuration.trackLongTasks,
347+
track_bfcache_views: configuration.trackBfcacheViews,
340348
plugins: configuration.plugins?.map((plugin) => ({
341349
name: plugin.name,
342350
...plugin.getConfigurationTelemetry?.(),
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createNewEvent, registerCleanupTask } from '@datadog/browser-core/test'
2+
import { mockRumConfiguration } from '../../../test'
3+
import { onBFCacheRestore } from './bfCacheSupport'
4+
5+
describe('onBFCacheRestore', () => {
6+
it('should invoke the callback only for BFCache restoration and stop listening when stopped', () => {
7+
const configuration = mockRumConfiguration()
8+
const callback = jasmine.createSpy('callback')
9+
10+
const stop = onBFCacheRestore(configuration, callback)
11+
registerCleanupTask(stop)
12+
13+
window.dispatchEvent(createNewEvent('pageshow', { persisted: false }))
14+
expect(callback).not.toHaveBeenCalled()
15+
16+
window.dispatchEvent(createNewEvent('pageshow', { persisted: true }))
17+
expect(callback).toHaveBeenCalledTimes(1)
18+
})
19+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Configuration } from '@datadog/browser-core'
2+
import { addEventListener, DOM_EVENT } from '@datadog/browser-core'
3+
4+
export function onBFCacheRestore(
5+
configuration: Configuration,
6+
callback: (event: PageTransitionEvent) => void
7+
): () => void {
8+
const { stop } = addEventListener(
9+
configuration,
10+
window,
11+
DOM_EVENT.PAGE_SHOW,
12+
(event: PageTransitionEvent) => {
13+
if (event.persisted) {
14+
callback(event)
15+
}
16+
},
17+
{ capture: true }
18+
)
19+
return stop
20+
}

packages/rum-core/src/domain/view/trackViews.spec.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from '@datadog/browser-core'
1010

1111
import type { Clock } from '@datadog/browser-core/test'
12-
import { mockClock, registerCleanupTask } from '@datadog/browser-core/test'
12+
import { mockClock, registerCleanupTask, createNewEvent } from '@datadog/browser-core/test'
1313
import { createPerformanceEntry, mockPerformanceObserver } from '../../../test'
1414
import { RumEventType, ViewLoadingType } from '../../rawRumEvent.types'
1515
import type { RumEvent } from '../../rumEvent.types'
@@ -1029,3 +1029,31 @@ describe('service and version', () => {
10291029
expect(getViewUpdate(0).version).toEqual('view version')
10301030
})
10311031
})
1032+
1033+
describe('BFCache views', () => {
1034+
const lifeCycle = new LifeCycle()
1035+
let viewTest: ViewTest
1036+
1037+
beforeEach(() => {
1038+
viewTest = setupViewTest({ lifeCycle, partialConfig: { trackBfcacheViews: true } })
1039+
1040+
registerCleanupTask(() => {
1041+
viewTest.stop()
1042+
})
1043+
})
1044+
1045+
it('should create a new "bf_cache" view when restoring from the BFCache', () => {
1046+
const { getViewCreateCount, getViewEndCount, getViewUpdate, getViewUpdateCount } = viewTest
1047+
1048+
expect(getViewCreateCount()).toBe(1)
1049+
expect(getViewEndCount()).toBe(0)
1050+
1051+
const event = createNewEvent('pageshow', { persisted: true })
1052+
1053+
window.dispatchEvent(event)
1054+
1055+
expect(getViewEndCount()).toBe(1)
1056+
expect(getViewCreateCount()).toBe(2)
1057+
expect(getViewUpdate(getViewUpdateCount() - 1).loadingType).toBe(ViewLoadingType.BF_CACHE)
1058+
})
1059+
})

packages/rum-core/src/domain/view/trackViews.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
throttle,
1818
clocksNow,
1919
clocksOrigin,
20+
relativeToClocks,
2021
timeStampNow,
2122
display,
2223
looksLikeRelativeTime,
@@ -39,6 +40,8 @@ import { trackInitialViewMetrics } from './viewMetrics/trackInitialViewMetrics'
3940
import type { InitialViewMetrics } from './viewMetrics/trackInitialViewMetrics'
4041
import type { CommonViewMetrics } from './viewMetrics/trackCommonViewMetrics'
4142
import { trackCommonViewMetrics } from './viewMetrics/trackCommonViewMetrics'
43+
import { onBFCacheRestore } from './bfCacheSupport'
44+
import { trackBfcacheMetrics } from './viewMetrics/trackBfcacheMetrics'
4245

4346
export interface ViewEvent {
4447
id: string
@@ -110,12 +113,20 @@ export function trackViews(
110113
) {
111114
const activeViews: Set<ReturnType<typeof newView>> = new Set()
112115
let currentView = startNewView(ViewLoadingType.INITIAL_LOAD, clocksOrigin(), initialViewOptions)
116+
let stopOnBFCacheRestore: (() => void) | undefined
113117

114118
startViewLifeCycle()
115119

116120
let locationChangeSubscription: Subscription
117121
if (areViewsTrackedAutomatically) {
118122
locationChangeSubscription = renewViewOnLocationChange(locationChangeObservable)
123+
if (configuration.trackBfcacheViews) {
124+
stopOnBFCacheRestore = onBFCacheRestore(configuration, (pageshowEvent) => {
125+
currentView.end()
126+
const startClocks = relativeToClocks(pageshowEvent.timeStamp as RelativeTime)
127+
currentView = startNewView(ViewLoadingType.BF_CACHE, startClocks, undefined)
128+
})
129+
}
119130
}
120131

121132
function startNewView(loadingType: ViewLoadingType, startClocks?: ClocksState, viewOptions?: ViewOptions) {
@@ -184,6 +195,9 @@ export function trackViews(
184195
if (locationChangeSubscription) {
185196
locationChangeSubscription.unsubscribe()
186197
}
198+
if (stopOnBFCacheRestore) {
199+
stopOnBFCacheRestore()
200+
}
187201
currentView.end()
188202
activeViews.forEach((view) => view.stop())
189203
},
@@ -256,6 +270,11 @@ function newView(
256270
? trackInitialViewMetrics(configuration, setLoadEvent, scheduleViewUpdate)
257271
: { stop: noop, initialViewMetrics: {} as InitialViewMetrics }
258272

273+
// Start BFCache-specific metrics when restoring from BFCache
274+
if (loadingType === ViewLoadingType.BF_CACHE) {
275+
trackBfcacheMetrics(startClocks, initialViewMetrics, scheduleViewUpdate)
276+
}
277+
259278
const { stop: stopEventCountsTracking, eventCounts } = trackViewEventCounts(lifeCycle, id, scheduleViewUpdate)
260279

261280
// Session keep alive
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Duration, RelativeTime, TimeStamp } from '@datadog/browser-core'
2+
import type { Clock } from '@datadog/browser-core/test'
3+
import { createNewEvent, mockClock, registerCleanupTask } from '@datadog/browser-core/test'
4+
import { trackBfcacheMetrics } from './trackBfcacheMetrics'
5+
import type { InitialViewMetrics } from './trackInitialViewMetrics'
6+
7+
describe('trackBfcacheMetrics', () => {
8+
let clock: Clock
9+
10+
beforeEach(() => {
11+
clock = mockClock()
12+
registerCleanupTask(clock.cleanup)
13+
14+
spyOn(window, 'requestAnimationFrame').and.callFake((cb: FrameRequestCallback): number => {
15+
cb(performance.now())
16+
return 0
17+
})
18+
})
19+
20+
function createPageshowEvent() {
21+
return createNewEvent('pageshow', { timeStamp: performance.now() })
22+
}
23+
24+
it('should compute FCP and LCP from the next frame after BFCache restore', () => {
25+
const pageshow = createPageshowEvent() as PageTransitionEvent
26+
27+
const metrics: InitialViewMetrics = {}
28+
const scheduleSpy = jasmine.createSpy('schedule')
29+
30+
clock.tick(50)
31+
32+
const startClocks = {
33+
relative: pageshow.timeStamp as RelativeTime,
34+
timeStamp: 0 as TimeStamp,
35+
}
36+
trackBfcacheMetrics(startClocks, metrics, scheduleSpy)
37+
38+
expect(metrics.firstContentfulPaint).toEqual(50 as Duration)
39+
expect(metrics.largestContentfulPaint?.value).toEqual(50 as RelativeTime)
40+
expect(scheduleSpy).toHaveBeenCalled()
41+
})
42+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { RelativeTime, ClocksState } from '@datadog/browser-core'
2+
import type { InitialViewMetrics } from './trackInitialViewMetrics'
3+
import { trackRestoredFirstContentfulPaint } from './trackFirstContentfulPaint'
4+
5+
/**
6+
* BFCache keeps a full in-memory snapshot of the DOM. When the page is restored, nothing needs to be fetched, so the whole
7+
* viewport repaints in a single frame. Consequently, LCP almost always equals FCP.
8+
* (See: https://github.com/GoogleChrome/web-vitals/pull/87)
9+
*/
10+
export function trackBfcacheMetrics(
11+
viewStart: ClocksState,
12+
metrics: InitialViewMetrics,
13+
scheduleViewUpdate: () => void
14+
) {
15+
trackRestoredFirstContentfulPaint(viewStart.relative, (paintTime) => {
16+
metrics.firstContentfulPaint = paintTime
17+
metrics.largestContentfulPaint = { value: paintTime as RelativeTime }
18+
scheduleViewUpdate()
19+
})
20+
}

packages/rum-core/src/domain/view/viewMetrics/trackFirstContentfulPaint.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { RelativeTime } from '@datadog/browser-core'
2-
import { ONE_MINUTE } from '@datadog/browser-core'
1+
import type { Duration, RelativeTime } from '@datadog/browser-core'
2+
import { ONE_MINUTE, elapsed, relativeNow } from '@datadog/browser-core'
33
import type { RumPerformancePaintTiming } from '../../../browser/performanceObservable'
44
import { createPerformanceObservable, RumPerformanceEntryType } from '../../../browser/performanceObservable'
55
import type { RumConfiguration } from '../../configuration'
@@ -32,3 +32,16 @@ export function trackFirstContentfulPaint(
3232
stop: performanceSubscription.unsubscribe,
3333
}
3434
}
35+
36+
/**
37+
* Measure the First Contentful Paint after a BFCache restoration.
38+
* The DOM is restored synchronously, so we approximate the FCP with the first frame
39+
* rendered just after the pageshow event, using two nested requestAnimationFrame calls.
40+
*/
41+
export function trackRestoredFirstContentfulPaint(viewStartRelative: RelativeTime, callback: (fcp: Duration) => void) {
42+
requestAnimationFrame(() => {
43+
requestAnimationFrame(() => {
44+
callback(elapsed(viewStartRelative, relativeNow()))
45+
})
46+
})
47+
}

packages/rum-core/src/domain/view/viewMetrics/trackLoadingTime.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import { ViewLoadingType } from '../../../rawRumEvent.types'
77
import type { RumMutationRecord } from '../../../browser/domMutationObservable'
88
import { trackFirstHidden } from './trackFirstHidden'
99

10+
/**
11+
* For non-initial views (such as route changes or BFCache restores), the regular load event does not fire
12+
* In these cases, trackLoadingTime can only emit a loadingTime if waitPageActivityEnd detects some post-restore activity.
13+
* If nothing happens after the view starts,no candidate is recorded and loadingTime stays undefined.
14+
*/
15+
1016
export function trackLoadingTime(
1117
lifeCycle: LifeCycle,
1218
domMutationObservable: Observable<RumMutationRecord[]>,

packages/rum-core/src/rawRumEvent.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ export type PageStateServerEntry = { state: PageState; start: ServerDuration }
188188
export const enum ViewLoadingType {
189189
INITIAL_LOAD = 'initial_load',
190190
ROUTE_CHANGE = 'route_change',
191+
BF_CACHE = 'bf_cache',
191192
}
192193

193194
export interface ViewCustomTimings {

0 commit comments

Comments
 (0)