Skip to content

Commit

Permalink
Integrate romanG/take-full-snapshot-asynchronously (#2887) into stagi…
Browse files Browse the repository at this point in the history
…ng-30

Integrated commit sha: 479736c

Co-authored-by: roman.gaignault <[email protected]>
  • Loading branch information
dd-mergequeue[bot] and RomanGaignault authored Jul 23, 2024
2 parents 2c42720 + 479736c commit cc5e316
Show file tree
Hide file tree
Showing 11 changed files with 198 additions and 40 deletions.
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
39 changes: 39 additions & 0 deletions packages/core/test/emulate/mockRequestIdleCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { registerCleanupTask } from '../registerCleanupTask'

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

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

function addCallback(callback: (...params: any[]) => any) {
const id = Math.random()
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
33 changes: 33 additions & 0 deletions packages/rum/src/browser/requestIdleCallBack.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { requestIdleCallback } from './requestIdleCallback'

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

beforeEach(() => {
requestAnimationFrameSpy = spyOn(window, 'requestAnimationFrame').and.callFake((cb) => {
cb(0)
return 123
})
cancelAnimationFrameSpy = spyOn(window, 'cancelAnimationFrame')
callback = jasmine.createSpy('callback')
})

afterEach(() => {
window.requestIdleCallback = originalRequestIdleCallback
window.cancelIdleCallback = originalCancelIdleCallback
})

it('should use requestAnimationFrame when requestIdleCallback is not defined', () => {
window.requestIdleCallback = undefined as any
window.cancelIdleCallback = undefined as any

const cancel = requestIdleCallback(callback)
expect(requestAnimationFrameSpy).toHaveBeenCalled()
cancel()
expect(cancelAnimationFrameSpy).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) {
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
36 changes: 26 additions & 10 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,17 +67,31 @@ export function startFullSnapshots(
return records
}

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

let cancelIdleCallback: (() => void) | undefined
const { unsubscribe } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => {
flushMutations()
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 {
Expand Down

0 comments on commit cc5e316

Please sign in to comment.