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-116] On view change, take the full snapshot asynchronously #2887

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions packages/core/src/tools/experimentalFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export enum ExperimentalFeature {
TOLERANT_RESOURCE_TIMINGS = 'tolerant_resource_timings',
REMOTE_CONFIGURATION = 'remote_configuration',
UPDATE_VIEW_NAME = 'update_view_name',
ASYNC_FULL_SNAPSHOT = 'async_full_snapshot',
}

const enabledExperimentalFeatures: Set<ExperimentalFeature> = new Set()
Expand Down
41 changes: 41 additions & 0 deletions packages/core/test/emulate/mockRequestIdleCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { registerCleanupTask } from '../registerCleanupTask'

let requestIdleCallbackSpy: jasmine.Spy
let cancelIdleCallbackSpy: jasmine.Spy

export function mockRequestIdleCallback() {
const callbacks = new Map<number, () => void>()

let idCounter = 0

function addCallback(callback: (...params: any[]) => any) {
const id = ++idCounter
callbacks.set(id, callback)
return id
}

function removeCallback(id: number) {
callbacks.delete(id)
}

if (!window.requestIdleCallback || !window.cancelIdleCallback) {
requestIdleCallbackSpy = spyOn(window, 'requestAnimationFrame').and.callFake(addCallback)
cancelIdleCallbackSpy = spyOn(window, 'cancelAnimationFrame').and.callFake(removeCallback)
} else {
requestIdleCallbackSpy = spyOn(window, 'requestIdleCallback').and.callFake(addCallback)
cancelIdleCallbackSpy = spyOn(window, 'cancelIdleCallback').and.callFake(removeCallback)
}

registerCleanupTask(() => {
requestIdleCallbackSpy.calls.reset()
cancelIdleCallbackSpy.calls.reset()
callbacks.clear()
})

return {
triggerIdleCallbacks: () => {
callbacks.forEach((callback) => callback())
},
cancelIdleCallbackSpy,
}
}
1 change: 1 addition & 0 deletions packages/core/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './interceptRequests'
export * from './emulate/createNewEvent'
export * from './emulate/mockLocation'
export * from './emulate/mockClock'
export * from './emulate/mockRequestIdleCallback'
export * from './emulate/mockReportingObserver'
export * from './emulate/mockZoneJs'
export * from './emulate/mockSyntheticsWorkerValues'
Expand Down
48 changes: 48 additions & 0 deletions packages/rum/src/browser/requestIdleCallBack.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { requestIdleCallback } from './requestIdleCallback'

describe('requestIdleCallback', () => {
let callback: jasmine.Spy
const originalRequestIdleCallback = window.requestIdleCallback

beforeEach(() => {
callback = jasmine.createSpy('callback')
})

afterEach(() => {
if (originalRequestIdleCallback) {
window.requestIdleCallback = originalRequestIdleCallback
}
})

it('should use requestIdleCallback when supported', () => {
if (!window.requestIdleCallback) {
pending('requestIdleCallback not supported')
}
spyOn(window, 'requestIdleCallback').and.callFake((cb) => {
cb({} as IdleDeadline)
return 123
})
spyOn(window, 'cancelIdleCallback')

const cancel = requestIdleCallback(callback)
expect(window.requestIdleCallback).toHaveBeenCalled()
cancel()
expect(window.cancelIdleCallback).toHaveBeenCalledWith(123)
})

it('should use requestAnimationFrame when requestIdleCallback is not supported', () => {
if (window.requestIdleCallback) {
window.requestIdleCallback = undefined as any
}
spyOn(window, 'requestAnimationFrame').and.callFake((cb) => {
cb(1)
return 123
})
spyOn(window, 'cancelAnimationFrame')

const cancel = requestIdleCallback(callback)
expect(window.requestAnimationFrame).toHaveBeenCalled()
cancel()
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(123)
})
})
19 changes: 19 additions & 0 deletions packages/rum/src/browser/requestIdleCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { monitor } from '@datadog/browser-core'

/**
* Use 'requestIdleCallback' when available: it will throttle the mutation processing if the
* browser is busy rendering frames (ex: when frames are below 60fps). When not available, the
* fallback on 'requestAnimationFrame' will still ensure the mutations are processed after any
* browser rendering process (Layout, Recalculate Style, etc.), so we can serialize DOM nodes efficiently.
*
* Note: check both 'requestIdleCallback' and 'cancelIdleCallback' existence because some polyfills only implement 'requestIdleCallback'.
*/

export function requestIdleCallback(callback: () => void, opts?: { timeout?: number }) {
if (window.requestIdleCallback && window.cancelIdleCallback) {
const id = window.requestIdleCallback(monitor(callback), opts)
return () => window.cancelIdleCallback(id)
}
const id = window.requestAnimationFrame(monitor(callback))
return () => window.cancelAnimationFrame(id)
}
1 change: 1 addition & 0 deletions packages/rum/src/domain/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { record } from './record'
export { serializeNodeWithId, serializeDocument, SerializationContextStatus } from './serialization'
export { createElementsScrollPositions } from './elementsScrollPositions'
export { ShadowRootsController } from './shadowRootsController'
export { startFullSnapshots } from './startFullSnapshots'
20 changes: 2 additions & 18 deletions packages/rum/src/domain/record/mutationBatch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { noop, monitor, throttle } from '@datadog/browser-core'
import { noop, throttle } from '@datadog/browser-core'
import { requestIdleCallback } from '../../browser/requestIdleCallback'
import type { RumMutationRecord } from './trackers'

/**
Expand Down Expand Up @@ -45,20 +46,3 @@ export function createMutationBatch(processMutationBatch: (mutations: RumMutatio
},
}
}

/**
* Use 'requestIdleCallback' when available: it will throttle the mutation processing if the
* browser is busy rendering frames (ex: when frames are below 60fps). When not available, the
* fallback on 'requestAnimationFrame' will still ensure the mutations are processed after any
* browser rendering process (Layout, Recalculate Style, etc.), so we can serialize DOM nodes efficiently.
*
* Note: check both 'requestIdleCallback' and 'cancelIdleCallback' existence because some polyfills only implement 'requestIdleCallback'.
*/
function requestIdleCallback(callback: () => void, opts?: { timeout?: number }) {
if (window.requestIdleCallback && window.cancelIdleCallback) {
const id = window.requestIdleCallback(monitor(callback), opts)
return () => window.cancelIdleCallback(id)
}
const id = window.requestAnimationFrame(monitor(callback))
return () => window.cancelAnimationFrame(id)
}
24 changes: 23 additions & 1 deletion packages/rum/src/domain/record/record.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { DefaultPrivacyLevel, findLast, isIE } from '@datadog/browser-core'
import type { RumConfiguration, ViewCreatedEvent } from '@datadog/browser-rum-core'
import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core'
import type { Clock } from '@datadog/browser-core/test'
import { createNewEvent, collectAsyncCalls, registerCleanupTask } from '@datadog/browser-core/test'
import {
createNewEvent,
collectAsyncCalls,
registerCleanupTask,
mockRequestIdleCallback,
mockExperimentalFeatures,
} from '@datadog/browser-core/test'
import { ExperimentalFeature } from '../../../../core/src/tools/experimentalFeatures'
import { findElement, findFullSnapshot, findNode, recordsPerFullSnapshot } from '../../../test'
import type {
BrowserIncrementalSnapshotRecord,
Expand Down Expand Up @@ -412,6 +419,21 @@ describe('record', () => {
}
})

describe('it should not record when full snapshot is pending', () => {
it('ignores any record while a full snapshot is pending', () => {
mockExperimentalFeatures([ExperimentalFeature.ASYNC_FULL_SNAPSHOT])
mockRequestIdleCallback()
startRecording()
newView()

emitSpy.calls.reset()

window.dispatchEvent(createNewEvent('focus'))

expect(getEmittedRecords().find((record) => record.type === RecordType.Focus)).toBeUndefined()
})
})

describe('updates record replay stats', () => {
it('when recording new records', () => {
resetReplayStats()
Expand Down
20 changes: 15 additions & 5 deletions packages/rum/src/domain/record/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,15 @@ export function record(options: RecordOptions): RecordAPI {
throw new Error('emit function is required')
}

let isFullSnapshotPending = false

const emitAndComputeStats = (record: BrowserRecord) => {
emit(record)
sendToExtension('record', { record })
const view = options.viewContexts.findView()!
replayStats.addRecord(view.id)
if (!isFullSnapshotPending) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ question: ‏By discarding records during the fullsnapshot are we confident not to miss important ones like scroll, mouseInteraction, move?

emit(record)
sendToExtension('record', { record })
const view = options.viewContexts.findView()!
replayStats.addRecord(view.id)
}
}

const elementsScrollPositions = createElementsScrollPositions()
Expand All @@ -59,7 +63,13 @@ export function record(options: RecordOptions): RecordAPI {
lifeCycle,
configuration,
flushMutations,
(records) => records.forEach((record) => emitAndComputeStats(record))
() => {
isFullSnapshotPending = true
},
(records) => {
isFullSnapshotPending = false
records.forEach((record) => emitAndComputeStats(record))
}
)

function flushMutations() {
Expand Down
44 changes: 38 additions & 6 deletions packages/rum/src/domain/record/startFullSnapshots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,51 +2,83 @@ import type { RumConfiguration, ViewCreatedEvent } from '@datadog/browser-rum-co
import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core'
import type { TimeStamp } from '@datadog/browser-core'
import { isIE, noop } from '@datadog/browser-core'
import { mockExperimentalFeatures, mockRequestIdleCallback } from '@datadog/browser-core/test'
import type { BrowserRecord } from '../../types'
import { ExperimentalFeature } from '../../../../core/src/tools/experimentalFeatures'
import { startFullSnapshots } from './startFullSnapshots'
import { createElementsScrollPositions } from './elementsScrollPositions'
import type { ShadowRootsController } from './shadowRootsController'

describe('startFullSnapshots', () => {
const viewStartClock = { relative: 1, timeStamp: 1 as TimeStamp }
let lifeCycle: LifeCycle
let fullSnapshotCallback: jasmine.Spy<(records: BrowserRecord[]) => void>
let fullSnapshotPendingCallback: jasmine.Spy<() => void>
let fullSnapshotReadyCallback: jasmine.Spy<(records: BrowserRecord[]) => void>

beforeEach(() => {
if (isIE()) {
pending('IE not supported')
}

lifeCycle = new LifeCycle()
fullSnapshotCallback = jasmine.createSpy()
mockExperimentalFeatures([ExperimentalFeature.ASYNC_FULL_SNAPSHOT])
fullSnapshotPendingCallback = jasmine.createSpy('fullSnapshotPendingCallback')
fullSnapshotReadyCallback = jasmine.createSpy('fullSnapshotReadyCallback')

startFullSnapshots(
createElementsScrollPositions(),
{} as ShadowRootsController,
lifeCycle,
{} as RumConfiguration,
noop,
fullSnapshotCallback
fullSnapshotPendingCallback,
fullSnapshotReadyCallback
)
})

it('takes a full snapshot when startFullSnapshots is called', () => {
expect(fullSnapshotCallback).toHaveBeenCalledTimes(1)
expect(fullSnapshotReadyCallback).toHaveBeenCalledTimes(1)
})

it('takes a full snapshot when the view changes', () => {
const { triggerIdleCallbacks } = mockRequestIdleCallback()

lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, {
startClocks: viewStartClock,
} as Partial<ViewCreatedEvent> as any)

triggerIdleCallbacks()

expect(fullSnapshotPendingCallback).toHaveBeenCalledTimes(1)
expect(fullSnapshotReadyCallback).toHaveBeenCalledTimes(2)
})

it('cancels the full snapshot if another view is created before it can it happens', () => {
const { triggerIdleCallbacks } = mockRequestIdleCallback()

lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, {
startClocks: viewStartClock,
} as Partial<ViewCreatedEvent> as any)

lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, {
startClocks: viewStartClock,
} as Partial<ViewCreatedEvent> as any)

expect(fullSnapshotCallback).toHaveBeenCalledTimes(2)
triggerIdleCallbacks()
expect(fullSnapshotPendingCallback).toHaveBeenCalledTimes(2)
expect(fullSnapshotReadyCallback).toHaveBeenCalledTimes(2)
})

it('full snapshot related records should have the view change date', () => {
const { triggerIdleCallbacks } = mockRequestIdleCallback()

lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, {
startClocks: viewStartClock,
} as Partial<ViewCreatedEvent> as any)

const records = fullSnapshotCallback.calls.mostRecent().args[0]
triggerIdleCallbacks()

const records = fullSnapshotReadyCallback.calls.mostRecent().args[0]
expect(records[0].timestamp).toEqual(1)
expect(records[1].timestamp).toEqual(1)
expect(records[2].timestamp).toEqual(1)
Expand Down
41 changes: 30 additions & 11 deletions packages/rum/src/domain/record/startFullSnapshots.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { LifeCycleEventType, getScrollX, getScrollY, getViewportDimension } from '@datadog/browser-rum-core'
import type { RumConfiguration, LifeCycle } from '@datadog/browser-rum-core'
import { timeStampNow } from '@datadog/browser-core'
import { timeStampNow, isExperimentalFeatureEnabled, ExperimentalFeature } from '@datadog/browser-core'
import { requestIdleCallback } from '../../browser/requestIdleCallback'
import type { BrowserRecord } from '../../types'
import { RecordType } from '../../types'
import type { ElementsScrollPositions } from './elementsScrollPositions'
Expand All @@ -14,7 +15,8 @@ export function startFullSnapshots(
lifeCycle: LifeCycle,
configuration: RumConfiguration,
flushMutations: () => void,
fullSnapshotCallback: (records: BrowserRecord[]) => void
fullSnapshotPendingCallback: () => void,
fullSnapshotReadyCallback: (records: BrowserRecord[]) => void
) {
const takeFullSnapshot = (
timestamp = timeStampNow(),
Expand Down Expand Up @@ -65,20 +67,37 @@ export function startFullSnapshots(
return records
}

fullSnapshotCallback(takeFullSnapshot())
fullSnapshotReadyCallback(takeFullSnapshot())

let cancelIdleCallback: (() => void) | undefined
const { unsubscribe } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => {
flushMutations()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ question: ‏Is this flushMutations() necessary?

fullSnapshotCallback(
takeFullSnapshot(view.startClocks.timeStamp, {
shadowRootsController,
status: SerializationContextStatus.SUBSEQUENT_FULL_SNAPSHOT,
elementsScrollPositions,
})
)
function takeSubsequentFullSnapshot() {
flushMutations()
fullSnapshotReadyCallback(
takeFullSnapshot(view.startClocks.timeStamp, {
shadowRootsController,
status: SerializationContextStatus.SUBSEQUENT_FULL_SNAPSHOT,
elementsScrollPositions,
})
)
}

if (isExperimentalFeatureEnabled(ExperimentalFeature.ASYNC_FULL_SNAPSHOT)) {
if (cancelIdleCallback) {
cancelIdleCallback()
}
fullSnapshotPendingCallback()
cancelIdleCallback = requestIdleCallback(takeSubsequentFullSnapshot)
} else {
takeSubsequentFullSnapshot()
}
})

return {
RomanGaignault marked this conversation as resolved.
Show resolved Hide resolved
stop: unsubscribe,
stop: () => {
unsubscribe()
cancelIdleCallback?.()
},
}
}