Skip to content

Commit cc5e316

Browse files
Integrate romanG/take-full-snapshot-asynchronously (#2887) into staging-30
Integrated commit sha: 479736c Co-authored-by: roman.gaignault <[email protected]>
2 parents 2c42720 + 479736c commit cc5e316

File tree

11 files changed

+198
-40
lines changed

11 files changed

+198
-40
lines changed

packages/core/src/tools/experimentalFeatures.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export enum ExperimentalFeature {
1919
TOLERANT_RESOURCE_TIMINGS = 'tolerant_resource_timings',
2020
REMOTE_CONFIGURATION = 'remote_configuration',
2121
UPDATE_VIEW_NAME = 'update_view_name',
22+
ASYNC_FULL_SNAPSHOT = 'async_full_snapshot',
2223
}
2324

2425
const enabledExperimentalFeatures: Set<ExperimentalFeature> = new Set()
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { registerCleanupTask } from '../registerCleanupTask'
2+
3+
let requestIdleCallbackSpy: jasmine.Spy
4+
let cancelIdleCallbackSpy: jasmine.Spy
5+
6+
export function mockRequestIdleCallback() {
7+
const callbacks = new Map<number, () => void>()
8+
9+
function addCallback(callback: (...params: any[]) => any) {
10+
const id = Math.random()
11+
callbacks.set(id, callback)
12+
return id
13+
}
14+
15+
function removeCallback(id: number) {
16+
callbacks.delete(id)
17+
}
18+
19+
if (!window.requestIdleCallback || !window.cancelIdleCallback) {
20+
requestIdleCallbackSpy = spyOn(window, 'requestAnimationFrame').and.callFake(addCallback)
21+
cancelIdleCallbackSpy = spyOn(window, 'cancelAnimationFrame').and.callFake(removeCallback)
22+
} else {
23+
requestIdleCallbackSpy = spyOn(window, 'requestIdleCallback').and.callFake(addCallback)
24+
cancelIdleCallbackSpy = spyOn(window, 'cancelIdleCallback').and.callFake(removeCallback)
25+
}
26+
27+
registerCleanupTask(() => {
28+
requestIdleCallbackSpy.calls.reset()
29+
cancelIdleCallbackSpy.calls.reset()
30+
callbacks.clear()
31+
})
32+
33+
return {
34+
triggerIdleCallbacks: () => {
35+
callbacks.forEach((callback) => callback())
36+
},
37+
cancelIdleCallbackSpy,
38+
}
39+
}

packages/core/test/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './interceptRequests'
77
export * from './emulate/createNewEvent'
88
export * from './emulate/mockLocation'
99
export * from './emulate/mockClock'
10+
export * from './emulate/mockRequestIdleCallback'
1011
export * from './emulate/mockReportingObserver'
1112
export * from './emulate/mockZoneJs'
1213
export * from './emulate/mockSyntheticsWorkerValues'
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { requestIdleCallback } from './requestIdleCallback'
2+
3+
describe('requestIdleCallback', () => {
4+
let requestAnimationFrameSpy: jasmine.Spy
5+
let cancelAnimationFrameSpy: jasmine.Spy
6+
let callback: jasmine.Spy
7+
const originalRequestIdleCallback = window.requestIdleCallback
8+
const originalCancelIdleCallback = window.cancelIdleCallback
9+
10+
beforeEach(() => {
11+
requestAnimationFrameSpy = spyOn(window, 'requestAnimationFrame').and.callFake((cb) => {
12+
cb(0)
13+
return 123
14+
})
15+
cancelAnimationFrameSpy = spyOn(window, 'cancelAnimationFrame')
16+
callback = jasmine.createSpy('callback')
17+
})
18+
19+
afterEach(() => {
20+
window.requestIdleCallback = originalRequestIdleCallback
21+
window.cancelIdleCallback = originalCancelIdleCallback
22+
})
23+
24+
it('should use requestAnimationFrame when requestIdleCallback is not defined', () => {
25+
window.requestIdleCallback = undefined as any
26+
window.cancelIdleCallback = undefined as any
27+
28+
const cancel = requestIdleCallback(callback)
29+
expect(requestAnimationFrameSpy).toHaveBeenCalled()
30+
cancel()
31+
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(123)
32+
})
33+
})
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { monitor } from '@datadog/browser-core'
2+
3+
/**
4+
* Use 'requestIdleCallback' when available: it will throttle the mutation processing if the
5+
* browser is busy rendering frames (ex: when frames are below 60fps). When not available, the
6+
* fallback on 'requestAnimationFrame' will still ensure the mutations are processed after any
7+
* browser rendering process (Layout, Recalculate Style, etc.), so we can serialize DOM nodes efficiently.
8+
*
9+
* Note: check both 'requestIdleCallback' and 'cancelIdleCallback' existence because some polyfills only implement 'requestIdleCallback'.
10+
*/
11+
12+
export function requestIdleCallback(callback: () => void, opts?: { timeout?: number }) {
13+
if (window.requestIdleCallback && window.cancelIdleCallback) {
14+
const id = window.requestIdleCallback(monitor(callback), opts)
15+
return () => window.cancelIdleCallback(id)
16+
}
17+
const id = window.requestAnimationFrame(monitor(callback))
18+
return () => window.cancelAnimationFrame(id)
19+
}

packages/rum/src/domain/record/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export { record } from './record'
22
export { serializeNodeWithId, serializeDocument, SerializationContextStatus } from './serialization'
33
export { createElementsScrollPositions } from './elementsScrollPositions'
44
export { ShadowRootsController } from './shadowRootsController'
5+
export { startFullSnapshots } from './startFullSnapshots'

packages/rum/src/domain/record/mutationBatch.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { noop, monitor, throttle } from '@datadog/browser-core'
1+
import { noop, throttle } from '@datadog/browser-core'
2+
import { requestIdleCallback } from '../../browser/requestIdleCallback'
23
import type { RumMutationRecord } from './trackers'
34

45
/**
@@ -45,20 +46,3 @@ export function createMutationBatch(processMutationBatch: (mutations: RumMutatio
4546
},
4647
}
4748
}
48-
49-
/**
50-
* Use 'requestIdleCallback' when available: it will throttle the mutation processing if the
51-
* browser is busy rendering frames (ex: when frames are below 60fps). When not available, the
52-
* fallback on 'requestAnimationFrame' will still ensure the mutations are processed after any
53-
* browser rendering process (Layout, Recalculate Style, etc.), so we can serialize DOM nodes efficiently.
54-
*
55-
* Note: check both 'requestIdleCallback' and 'cancelIdleCallback' existence because some polyfills only implement 'requestIdleCallback'.
56-
*/
57-
function requestIdleCallback(callback: () => void, opts?: { timeout?: number }) {
58-
if (window.requestIdleCallback && window.cancelIdleCallback) {
59-
const id = window.requestIdleCallback(monitor(callback), opts)
60-
return () => window.cancelIdleCallback(id)
61-
}
62-
const id = window.requestAnimationFrame(monitor(callback))
63-
return () => window.cancelAnimationFrame(id)
64-
}

packages/rum/src/domain/record/record.spec.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ import { DefaultPrivacyLevel, findLast, isIE } from '@datadog/browser-core'
22
import type { RumConfiguration, ViewCreatedEvent } from '@datadog/browser-rum-core'
33
import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core'
44
import type { Clock } from '@datadog/browser-core/test'
5-
import { createNewEvent, collectAsyncCalls, registerCleanupTask } from '@datadog/browser-core/test'
5+
import {
6+
createNewEvent,
7+
collectAsyncCalls,
8+
registerCleanupTask,
9+
mockRequestIdleCallback,
10+
mockExperimentalFeatures,
11+
} from '@datadog/browser-core/test'
12+
import { ExperimentalFeature } from '../../../../core/src/tools/experimentalFeatures'
613
import { findElement, findFullSnapshot, findNode, recordsPerFullSnapshot } from '../../../test'
714
import type {
815
BrowserIncrementalSnapshotRecord,
@@ -412,6 +419,21 @@ describe('record', () => {
412419
}
413420
})
414421

422+
describe('it should not record when full snapshot is pending', () => {
423+
it('ignores any record while a full snapshot is pending', () => {
424+
mockExperimentalFeatures([ExperimentalFeature.ASYNC_FULL_SNAPSHOT])
425+
mockRequestIdleCallback()
426+
startRecording()
427+
newView()
428+
429+
emitSpy.calls.reset()
430+
431+
window.dispatchEvent(createNewEvent('focus'))
432+
433+
expect(getEmittedRecords().find((record) => record.type === RecordType.Focus)).toBeUndefined()
434+
})
435+
})
436+
415437
describe('updates record replay stats', () => {
416438
it('when recording new records', () => {
417439
resetReplayStats()

packages/rum/src/domain/record/record.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,15 @@ export function record(options: RecordOptions): RecordAPI {
4242
throw new Error('emit function is required')
4343
}
4444

45+
let isFullSnapshotPending = false
46+
4547
const emitAndComputeStats = (record: BrowserRecord) => {
46-
emit(record)
47-
sendToExtension('record', { record })
48-
const view = options.viewContexts.findView()!
49-
replayStats.addRecord(view.id)
48+
if (!isFullSnapshotPending) {
49+
emit(record)
50+
sendToExtension('record', { record })
51+
const view = options.viewContexts.findView()!
52+
replayStats.addRecord(view.id)
53+
}
5054
}
5155

5256
const elementsScrollPositions = createElementsScrollPositions()
@@ -59,7 +63,13 @@ export function record(options: RecordOptions): RecordAPI {
5963
lifeCycle,
6064
configuration,
6165
flushMutations,
62-
(records) => records.forEach((record) => emitAndComputeStats(record))
66+
() => {
67+
isFullSnapshotPending = true
68+
},
69+
(records) => {
70+
isFullSnapshotPending = false
71+
records.forEach((record) => emitAndComputeStats(record))
72+
}
6373
)
6474

6575
function flushMutations() {

packages/rum/src/domain/record/startFullSnapshots.spec.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,51 +2,83 @@ import type { RumConfiguration, ViewCreatedEvent } from '@datadog/browser-rum-co
22
import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core'
33
import type { TimeStamp } from '@datadog/browser-core'
44
import { isIE, noop } from '@datadog/browser-core'
5+
import { mockExperimentalFeatures, mockRequestIdleCallback } from '@datadog/browser-core/test'
56
import type { BrowserRecord } from '../../types'
7+
import { ExperimentalFeature } from '../../../../core/src/tools/experimentalFeatures'
68
import { startFullSnapshots } from './startFullSnapshots'
79
import { createElementsScrollPositions } from './elementsScrollPositions'
810
import type { ShadowRootsController } from './shadowRootsController'
911

1012
describe('startFullSnapshots', () => {
1113
const viewStartClock = { relative: 1, timeStamp: 1 as TimeStamp }
1214
let lifeCycle: LifeCycle
13-
let fullSnapshotCallback: jasmine.Spy<(records: BrowserRecord[]) => void>
15+
let fullSnapshotPendingCallback: jasmine.Spy<() => void>
16+
let fullSnapshotReadyCallback: jasmine.Spy<(records: BrowserRecord[]) => void>
1417

1518
beforeEach(() => {
1619
if (isIE()) {
1720
pending('IE not supported')
1821
}
1922

2023
lifeCycle = new LifeCycle()
21-
fullSnapshotCallback = jasmine.createSpy()
24+
mockExperimentalFeatures([ExperimentalFeature.ASYNC_FULL_SNAPSHOT])
25+
fullSnapshotPendingCallback = jasmine.createSpy('fullSnapshotPendingCallback')
26+
fullSnapshotReadyCallback = jasmine.createSpy('fullSnapshotReadyCallback')
27+
2228
startFullSnapshots(
2329
createElementsScrollPositions(),
2430
{} as ShadowRootsController,
2531
lifeCycle,
2632
{} as RumConfiguration,
2733
noop,
28-
fullSnapshotCallback
34+
fullSnapshotPendingCallback,
35+
fullSnapshotReadyCallback
2936
)
3037
})
3138

3239
it('takes a full snapshot when startFullSnapshots is called', () => {
33-
expect(fullSnapshotCallback).toHaveBeenCalledTimes(1)
40+
expect(fullSnapshotReadyCallback).toHaveBeenCalledTimes(1)
3441
})
3542

3643
it('takes a full snapshot when the view changes', () => {
44+
const { triggerIdleCallbacks } = mockRequestIdleCallback()
45+
46+
lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, {
47+
startClocks: viewStartClock,
48+
} as Partial<ViewCreatedEvent> as any)
49+
50+
triggerIdleCallbacks()
51+
52+
expect(fullSnapshotPendingCallback).toHaveBeenCalledTimes(1)
53+
expect(fullSnapshotReadyCallback).toHaveBeenCalledTimes(2)
54+
})
55+
56+
it('cancels the full snapshot if another view is created before it can it happens', () => {
57+
const { triggerIdleCallbacks } = mockRequestIdleCallback()
58+
59+
lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, {
60+
startClocks: viewStartClock,
61+
} as Partial<ViewCreatedEvent> as any)
62+
3763
lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, {
3864
startClocks: viewStartClock,
3965
} as Partial<ViewCreatedEvent> as any)
4066

41-
expect(fullSnapshotCallback).toHaveBeenCalledTimes(2)
67+
triggerIdleCallbacks()
68+
expect(fullSnapshotPendingCallback).toHaveBeenCalledTimes(2)
69+
expect(fullSnapshotReadyCallback).toHaveBeenCalledTimes(2)
4270
})
4371

4472
it('full snapshot related records should have the view change date', () => {
73+
const { triggerIdleCallbacks } = mockRequestIdleCallback()
74+
4575
lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, {
4676
startClocks: viewStartClock,
4777
} as Partial<ViewCreatedEvent> as any)
4878

49-
const records = fullSnapshotCallback.calls.mostRecent().args[0]
79+
triggerIdleCallbacks()
80+
81+
const records = fullSnapshotReadyCallback.calls.mostRecent().args[0]
5082
expect(records[0].timestamp).toEqual(1)
5183
expect(records[1].timestamp).toEqual(1)
5284
expect(records[2].timestamp).toEqual(1)

0 commit comments

Comments
 (0)