Skip to content

✨ [RUM-246] Support prerendered pages for web vitals #3617

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

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
31361ac
Added calculation for prerendered pages
BeltranBulbarellaDD Jun 10, 2025
07a0bb1
added e2e test, fixed tests to run only on supported browsers
BeltranBulbarellaDD Jun 10, 2025
158a758
Merge branch 'main' into HEAD
BeltranBulbarellaDD Jun 10, 2025
8d3cd23
yarn lock
BeltranBulbarellaDD Jun 10, 2025
5dfede3
fix: fix type error
BeltranBulbarellaDD Jun 10, 2025
19cddb6
fix format
BeltranBulbarellaDD Jun 10, 2025
9d4f5df
feat: add extra check for prerendering support
BeltranBulbarellaDD Jun 11, 2025
b8fd9cc
feat: add extra check for prerendering support
BeltranBulbarellaDD Jun 11, 2025
6394cc9
fix format
BeltranBulbarellaDD Jun 11, 2025
d7bc7d6
Guard prerendering check with isPrerenderingSupported
BeltranBulbarellaDD Jun 11, 2025
a9dd14b
remove unnecessary test
BeltranBulbarellaDD Jun 11, 2025
7a6acd8
Fix first hidden timeStamp
BeltranBulbarellaDD Jun 11, 2025
8f6787c
refactored code, removed unnecessary logs
BeltranBulbarellaDD Jun 12, 2025
0cd27f0
Merge branch 'main' into beltran.bulbarella/RUM-246-support-prerender…
BeltranBulbarellaDD Jun 25, 2025
b719b5d
add new view loading type prerendered
BeltranBulbarellaDD Jun 26, 2025
376eb85
Merge branch 'main' into beltran.bulbarella/RUM-246-support-prerender…
BeltranBulbarellaDD Jun 26, 2025
e7136a7
adding duplicated calculation
BeltranBulbarellaDD Jun 26, 2025
2648ea4
Revert "adding duplicated calculation"
BeltranBulbarellaDD Jun 26, 2025
36a84d3
remove unnecesary change
BeltranBulbarellaDD Jun 26, 2025
97f9a53
remove unused code
BeltranBulbarellaDD Jun 26, 2025
3944821
added trackPrerenderMetrics, removed calculations form the view metri…
BeltranBulbarellaDD Jun 26, 2025
1ad516d
remove logs
BeltranBulbarellaDD Jun 26, 2025
5f259db
Merge branch 'main' into beltran.bulbarella/RUM-246-support-prerender…
BeltranBulbarellaDD Jul 3, 2025
8674044
fix first byte and lcp metrics
BeltranBulbarellaDD Jul 7, 2025
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
44 changes: 44 additions & 0 deletions packages/core/src/browser/browser.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,47 @@ export type CookieChangeEvent = Event & {
changed: CookieChangeItem[]
deleted: CookieChangeItem[]
}

// Document interface extension for the prerendering API
// https://developer.mozilla.org/en-US/docs/Web/API/Document/prerendering
export interface DocumentWithPrerendering extends Document {
prerendering?: boolean
}

/**
* Check whether the Prerender API is supported by the browser.
* The prerendering property is only available in Chrome 108+ and Edge 108+.
* https://developer.mozilla.org/en-US/docs/Web/API/Document/prerendering
* Fully typed to prevent runtime errors when the property is not available.
*/
export function isPrerenderingSupported(): boolean {
try {
return typeof document !== 'undefined' && typeof (document as DocumentWithPrerendering).prerendering !== 'undefined'
} catch {
return false
}
}

/**
* Detect if the current page is or was prerendered.
* This checks both the current prerendering state and the presence of activationStart > 0.
*/
export function isPagePrerendered(): boolean {
try {
if (isPrerenderingSupported() && (document as DocumentWithPrerendering)?.prerendering) {
return true
}

if (typeof performance !== 'undefined' && performance.getEntriesByType) {
const navigationEntries = performance.getEntriesByType('navigation')
if (navigationEntries.length > 0) {
const navEntry = navigationEntries[0] as any
return navEntry.activationStart > 0
}
}

return false
} catch {
return false
}
}
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ export {
deleteCookie,
resetInitCookies,
} from './browser/cookie'
export type { CookieStore, WeakRef, WeakRefConstructor } from './browser/browser.types'
export type { CookieStore, WeakRef, WeakRefConstructor, DocumentWithPrerendering } from './browser/browser.types'
export { isPrerenderingSupported, isPagePrerendered } from './browser/browser.types'
export type { XhrCompleteContext, XhrStartContext } from './browser/xhrObservable'
export { initXhrObservable } from './browser/xhrObservable'
export type { FetchResolveContext, FetchStartContext, FetchContext } from './browser/fetchObservable'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -960,7 +960,7 @@ Error: foo
})

it('should normalize non native errors stacktraces across browsers', () => {
/* eslint-disable no-restricted-syntax */
class DatadogTestCustomError extends Error {
constructor() {
super()
Expand Down
1 change: 1 addition & 0 deletions packages/rum-core/src/browser/performanceObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface RumPerformanceResourceTiming {
name: string
startTime: RelativeTime
duration: Duration
activationStart?: RelativeTime
fetchStart: RelativeTime
workerStart: RelativeTime
domainLookupStart: RelativeTime
Expand Down
1 change: 1 addition & 0 deletions packages/rum-core/src/browser/performanceUtils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe('getNavigationEntry', () => {

startTime: 0 as RelativeTime,
duration: jasmine.any(Number),
activationStart: 0 as RelativeTime,

fetchStart: jasmine.any(Number),
workerStart: jasmine.any(Number),
Expand Down
17 changes: 12 additions & 5 deletions packages/rum-core/src/browser/performanceUtils.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import type { RelativeTime, TimeStamp } from '@datadog/browser-core'
import { getRelativeTime, isNumber } from '@datadog/browser-core'
import {
RumPerformanceEntryType,
supportPerformanceTimingEvent,
type RumPerformanceNavigationTiming,
} from './performanceObservable'
import { RumPerformanceEntryType, supportPerformanceTimingEvent } from './performanceObservable'
import type { RumPerformanceNavigationTiming } from './performanceObservable'

export function getNavigationEntry(): RumPerformanceNavigationTiming {
if (supportPerformanceTimingEvent(RumPerformanceEntryType.NAVIGATION)) {
const navigationEntry = performance.getEntriesByType(
RumPerformanceEntryType.NAVIGATION
)[0] as unknown as RumPerformanceNavigationTiming
if (navigationEntry) {
// Ensure activationStart is always present for compatibility with older browsers
if (navigationEntry.activationStart === undefined) {
navigationEntry.activationStart = 0 as RelativeTime
}
return navigationEntry
}
}
Expand All @@ -20,6 +21,7 @@ export function getNavigationEntry(): RumPerformanceNavigationTiming {
const entry: RumPerformanceNavigationTiming = {
entryType: RumPerformanceEntryType.NAVIGATION as const,
initiatorType: 'navigation' as const,
activationStart: 0 as RelativeTime,
name: window.location.href,
startTime: 0 as RelativeTime,
duration: timings.loadEventEnd,
Expand Down Expand Up @@ -51,3 +53,8 @@ export function computeTimingsFromDeprecatedPerformanceTiming() {
}
return result as TimingsFromDeprecatedPerformanceTiming
}

export function getActivationStart(): RelativeTime {
const navEntry = getNavigationEntry()
return (navEntry && navEntry.activationStart) || (0 as RelativeTime)
}
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ describe('serializeRumConfiguration', () => {
trackResources: true,
trackLongTasks: true,
trackBfcacheViews: true,
trackPrerenderViews: true,
remoteConfigurationId: '123',
plugins: [{ name: 'foo', getConfigurationTelemetry: () => ({ bar: true }) }],
trackFeatureFlagsForEvents: ['vital'],
Expand Down Expand Up @@ -580,6 +581,7 @@ describe('serializeRumConfiguration', () => {
track_resources: true,
track_long_task: true,
track_bfcache_views: true,
track_prerender_views: true,
use_worker_url: true,
compress_intake_requests: true,
plugins: [{ name: 'foo', bar: true }],
Expand Down
8 changes: 8 additions & 0 deletions packages/rum-core/src/domain/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ export interface RumInitConfiguration extends InitConfiguration {
* @default false
*/
trackBfcacheViews?: boolean | undefined
/**
* Enable the creation of dedicated views for pages prerendered.
* @default false
*/
trackPrerenderViews?: boolean | undefined
/**
* Enables collection of resource events.
* @default true
Expand Down Expand Up @@ -179,6 +184,7 @@ export interface RumConfiguration extends Configuration {
trackResources: boolean
trackLongTasks: boolean
trackBfcacheViews: boolean
trackPrerenderViews: boolean
version?: string
subdomain?: string
customerDataTelemetrySampleRate: number
Expand Down Expand Up @@ -248,6 +254,7 @@ export function validateAndBuildRumConfiguration(
trackResources: !!(initConfiguration.trackResources ?? true),
trackLongTasks: !!(initConfiguration.trackLongTasks ?? true),
trackBfcacheViews: !!initConfiguration.trackBfcacheViews,
trackPrerenderViews: !!initConfiguration.trackPrerenderViews,
subdomain: initConfiguration.subdomain,
defaultPrivacyLevel: objectHasValue(DefaultPrivacyLevel, initConfiguration.defaultPrivacyLevel)
? initConfiguration.defaultPrivacyLevel
Expand Down Expand Up @@ -341,6 +348,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {
track_resources: configuration.trackResources,
track_long_task: configuration.trackLongTasks,
track_bfcache_views: configuration.trackBfcacheViews,
track_prerender_views: configuration.trackPrerenderViews,
plugins: configuration.plugins?.map((plugin) => ({
name: plugin.name,
...plugin.getConfigurationTelemetry?.(),
Expand Down
25 changes: 25 additions & 0 deletions packages/rum-core/src/domain/view/trackViews.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1048,3 +1048,28 @@ describe('BFCache views', () => {
expect(getViewUpdate(getViewUpdateCount() - 1).loadingType).toBe(ViewLoadingType.BF_CACHE)
})
})

describe('Prerendered views', () => {
const lifeCycle = new LifeCycle()
let viewTest: ViewTest

beforeEach(() => {
const mockNavigationEntry = {
activationStart: 100,
entryType: 'navigation',
}
spyOn(performance, 'getEntriesByType').and.returnValue([mockNavigationEntry] as any)

viewTest = setupViewTest({ lifeCycle, partialConfig: { trackPrerenderViews: true } })

registerCleanupTask(() => {
viewTest.stop()
})
})

it('should create initial view with "prerendered" loading type when page has activationStart > 0', () => {
const { getViewUpdate } = viewTest

expect(getViewUpdate(0).loadingType).toBe(ViewLoadingType.PRERENDERED)
})
})
18 changes: 15 additions & 3 deletions packages/rum-core/src/domain/view/trackViews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
setTimeout,
Observable,
createContextManager,
isPagePrerendered,
} from '@datadog/browser-core'
import type { ViewCustomTimings } from '../../rawRumEvent.types'
import { ViewLoadingType } from '../../rawRumEvent.types'
Expand Down Expand Up @@ -112,7 +113,12 @@ export function trackViews(
initialViewOptions?: ViewOptions
) {
const activeViews: Set<ReturnType<typeof newView>> = new Set()
let currentView = startNewView(ViewLoadingType.INITIAL_LOAD, clocksOrigin(), initialViewOptions)

const initialLoadingType =
configuration.trackPrerenderViews && isPagePrerendered()
? ViewLoadingType.PRERENDERED
: ViewLoadingType.INITIAL_LOAD
let currentView = startNewView(initialLoadingType, clocksOrigin(), initialViewOptions)
let stopOnBFCacheRestore: (() => void) | undefined

startViewLifeCycle()
Expand Down Expand Up @@ -266,8 +272,14 @@ function newView(
)

const { stop: stopInitialViewMetricsTracking, initialViewMetrics } =
loadingType === ViewLoadingType.INITIAL_LOAD
? trackInitialViewMetrics(configuration, startClocks, setLoadEvent, scheduleViewUpdate)
loadingType === ViewLoadingType.INITIAL_LOAD || loadingType === ViewLoadingType.PRERENDERED
? trackInitialViewMetrics(
configuration,
startClocks,
setLoadEvent,
scheduleViewUpdate,
loadingType === ViewLoadingType.PRERENDERED
)
: { stop: noop, initialViewMetrics: {} as InitialViewMetrics }

// Start BFCache-specific metrics when restoring from BFCache
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { trackNavigationTimings } from './trackNavigationTimings'
import type { LargestContentfulPaint } from './trackLargestContentfulPaint'
import { trackLargestContentfulPaint } from './trackLargestContentfulPaint'
import { trackFirstHidden } from './trackFirstHidden'
import { trackPrerenderMetrics } from './trackPrerenderMetrics'

export interface InitialViewMetrics {
firstContentfulPaint?: Duration
Expand All @@ -20,10 +21,15 @@ export function trackInitialViewMetrics(
configuration: RumConfiguration,
viewStart: ClocksState,
setLoadEvent: (loadEnd: Duration) => void,
scheduleViewUpdate: () => void
scheduleViewUpdate: () => void,
isPrerendered = false
) {
const initialViewMetrics: InitialViewMetrics = {}

if (isPrerendered) {
trackPrerenderMetrics(configuration, initialViewMetrics, scheduleViewUpdate)
}

const { stop: stopNavigationTracking } = trackNavigationTimings(configuration, (navigationTimings) => {
setLoadEvent(navigationTimings.loadEvent)
initialViewMetrics.navigationTimings = navigationTimings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,10 @@ function trackLongestInteractions(getViewInteractionCount: () => number) {

export function trackViewInteractionCount(viewLoadingType: ViewLoadingType) {
initInteractionCountPolyfill()
const previousInteractionCount = viewLoadingType === ViewLoadingType.INITIAL_LOAD ? 0 : getInteractionCount()
const previousInteractionCount =
viewLoadingType === ViewLoadingType.INITIAL_LOAD || viewLoadingType === ViewLoadingType.PRERENDERED
? 0
: getInteractionCount()
let state: { stopped: false } | { stopped: true; interactionCount: number } = { stopped: false }

function computeViewInteractionCount() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function trackLoadingTime(
viewStart: ClocksState,
callback: (loadingTime: Duration) => void
) {
let isWaitingForLoadEvent = loadType === ViewLoadingType.INITIAL_LOAD
let isWaitingForLoadEvent = loadType === ViewLoadingType.INITIAL_LOAD || loadType === ViewLoadingType.PRERENDERED
let isWaitingForActivityLoadingTime = true
const loadingTimeCandidates: Duration[] = []
const firstHidden = trackFirstHidden(configuration, viewStart)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { RelativeTime, Duration } from '@datadog/browser-core'
import { mockRumConfiguration } from '../../../../test'
import type { RumConfiguration } from '../../configuration'
import type { InitialViewMetrics } from './trackInitialViewMetrics'
import { trackPrerenderMetrics } from './trackPrerenderMetrics'

describe('trackPrerenderMetrics', () => {
let configuration: RumConfiguration
let metrics: InitialViewMetrics
let scheduleViewUpdate: jasmine.Spy
let mockGetActivationStart: jasmine.Spy

beforeEach(() => {
configuration = mockRumConfiguration()

metrics = {
navigationTimings: {
firstByte: 100 as Duration,
},
} as InitialViewMetrics

scheduleViewUpdate = jasmine.createSpy('scheduleViewUpdate')
mockGetActivationStart = jasmine.createSpy('getActivationStart').and.returnValue(50 as RelativeTime)
})

describe('when activationStart is available', () => {
it('should adjust TTFB when activationStart is available', () => {
trackPrerenderMetrics(configuration, metrics, scheduleViewUpdate, mockGetActivationStart)

expect(metrics.navigationTimings!.firstByte).toBe(50 as Duration)
expect(scheduleViewUpdate).toHaveBeenCalled()
})

it('should not adjust metrics when activationStart is 0', () => {
mockGetActivationStart.and.returnValue(0 as RelativeTime)

trackPrerenderMetrics(configuration, metrics, scheduleViewUpdate, mockGetActivationStart)

expect(metrics.navigationTimings!.firstByte).toBe(100 as Duration)
expect(scheduleViewUpdate).not.toHaveBeenCalled()
})

it('should adjust FCP when available and greater than activationStart', () => {
metrics.firstContentfulPaint = 150 as Duration

trackPrerenderMetrics(configuration, metrics, scheduleViewUpdate, mockGetActivationStart)

expect(metrics.firstContentfulPaint).toBe(100 as Duration)
expect(scheduleViewUpdate).toHaveBeenCalled()
})

it('should not adjust FCP when less than activationStart', () => {
metrics.firstContentfulPaint = 30 as Duration

trackPrerenderMetrics(configuration, metrics, scheduleViewUpdate, mockGetActivationStart)

expect(metrics.firstContentfulPaint).toBe(0 as Duration)
expect(scheduleViewUpdate).toHaveBeenCalled()
})

it('should adjust LCP when available and greater than activationStart', () => {
metrics.largestContentfulPaint = {
value: 200 as RelativeTime,
targetSelector: '#lcp-element',
}

trackPrerenderMetrics(configuration, metrics, scheduleViewUpdate, mockGetActivationStart)

expect(metrics.largestContentfulPaint.value).toBe(150 as RelativeTime)
expect(metrics.largestContentfulPaint.targetSelector).toBe('#lcp-element')
expect(scheduleViewUpdate).toHaveBeenCalled()
})

it('should not adjust LCP when less than activationStart', () => {
metrics.largestContentfulPaint = {
value: 30 as RelativeTime,
targetSelector: '#lcp-element',
}

trackPrerenderMetrics(configuration, metrics, scheduleViewUpdate, mockGetActivationStart)

expect(metrics.largestContentfulPaint.value).toBe(0 as RelativeTime)
expect(scheduleViewUpdate).toHaveBeenCalled()
})

it('should handle multiple metrics adjustments', () => {
metrics.firstContentfulPaint = 150 as Duration
metrics.largestContentfulPaint = {
value: 200 as RelativeTime,
targetSelector: '#lcp-element',
}

trackPrerenderMetrics(configuration, metrics, scheduleViewUpdate, mockGetActivationStart)

expect(metrics.navigationTimings!.firstByte).toBe(50 as Duration)
expect(metrics.firstContentfulPaint).toBe(100 as Duration)
expect(metrics.largestContentfulPaint.value).toBe(150 as RelativeTime)
expect(scheduleViewUpdate).toHaveBeenCalled()
})

it('should not call scheduleViewUpdate when no metrics need adjustment', () => {
metrics = {} as InitialViewMetrics

trackPrerenderMetrics(configuration, metrics, scheduleViewUpdate, mockGetActivationStart)

expect(scheduleViewUpdate).not.toHaveBeenCalled()
})
})
})
Loading