diff --git a/packages/core/src/domain/configuration/configuration.spec.ts b/packages/core/src/domain/configuration/configuration.spec.ts
index 5344e567f4..aa4a74e4b2 100644
--- a/packages/core/src/domain/configuration/configuration.spec.ts
+++ b/packages/core/src/domain/configuration/configuration.spec.ts
@@ -2,11 +2,7 @@ import type { RumEvent } from '../../../../rum-core/src'
import { EXHAUSTIVE_INIT_CONFIGURATION, SERIALIZED_EXHAUSTIVE_INIT_CONFIGURATION } from '../../../test'
import type { ExtractTelemetryConfiguration, MapInitConfigurationKey } from '../../../test'
import { DOCS_ORIGIN, MORE_DETAILS, display } from '../../tools/display'
-import {
- ExperimentalFeature,
- isExperimentalFeatureEnabled,
- resetExperimentalFeatures,
-} from '../../tools/experimentalFeatures'
+import { ExperimentalFeature, isExperimentalFeatureEnabled } from '../../tools/experimentalFeatures'
import { SessionPersistence } from '../session/sessionConstants'
import { TrackingConsent } from '../trackingConsent'
import type { InitConfiguration } from './configuration'
@@ -21,10 +17,6 @@ describe('validateAndBuildConfiguration', () => {
displaySpy = spyOn(display, 'error')
})
- afterEach(() => {
- resetExperimentalFeatures()
- })
-
describe('experimentalFeatures', () => {
const TEST_FEATURE_FLAG = 'foo' as ExperimentalFeature
diff --git a/packages/core/src/domain/telemetry/telemetry.spec.ts b/packages/core/src/domain/telemetry/telemetry.spec.ts
index 3c591d89b9..d98ad4d569 100644
--- a/packages/core/src/domain/telemetry/telemetry.spec.ts
+++ b/packages/core/src/domain/telemetry/telemetry.spec.ts
@@ -2,7 +2,7 @@ import type { TimeStamp } from '@datadog/browser-rum/internal'
import { NO_ERROR_STACK_PRESENT_MESSAGE } from '../error/error'
import { callMonitored } from '../../tools/monitor'
import type { ExperimentalFeature } from '../../tools/experimentalFeatures'
-import { resetExperimentalFeatures, addExperimentalFeatures } from '../../tools/experimentalFeatures'
+import { addExperimentalFeatures } from '../../tools/experimentalFeatures'
import { validateAndBuildConfiguration, type Configuration } from '../configuration'
import { INTAKE_SITE_US1_FED, INTAKE_SITE_US1 } from '../intakeSites'
import {
@@ -87,10 +87,6 @@ describe('telemetry', () => {
})
describe('addTelemetryConfiguration', () => {
- afterEach(() => {
- resetExperimentalFeatures()
- })
-
it('should collects configuration when sampled', async () => {
const { getTelemetryEvents } = startAndSpyTelemetry({
telemetrySampleRate: 100,
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 12603eb5d0..4c2e3dba14 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -14,7 +14,6 @@ export { TrackingConsent, createTrackingConsentState } from './domain/trackingCo
export {
isExperimentalFeatureEnabled,
addExperimentalFeatures,
- resetExperimentalFeatures,
getExperimentalFeatures,
initFeatureFlags,
ExperimentalFeature,
diff --git a/packages/core/src/tools/experimentalFeatures.spec.ts b/packages/core/src/tools/experimentalFeatures.spec.ts
index 1726165ba5..0655d567b5 100644
--- a/packages/core/src/tools/experimentalFeatures.spec.ts
+++ b/packages/core/src/tools/experimentalFeatures.spec.ts
@@ -3,17 +3,12 @@ import {
addExperimentalFeatures,
initFeatureFlags,
isExperimentalFeatureEnabled,
- resetExperimentalFeatures,
} from './experimentalFeatures'
const TEST_FEATURE_FLAG_ONE = 'foo' as ExperimentalFeature
const TEST_FEATURE_FLAG_TWO = 'bar' as ExperimentalFeature
describe('experimentalFeatures', () => {
- afterEach(() => {
- resetExperimentalFeatures()
- })
-
it('initial state is empty', () => {
expect(isExperimentalFeatureEnabled(TEST_FEATURE_FLAG_ONE)).toBeFalse()
expect(isExperimentalFeatureEnabled(TEST_FEATURE_FLAG_TWO)).toBeFalse()
@@ -41,7 +36,6 @@ describe('initFeatureFlags', () => {
afterEach(() => {
delete (ExperimentalFeature as any).FOO
- resetExperimentalFeatures()
})
it('ignores unknown experimental features', () => {
diff --git a/packages/core/test/emulate/mockExperimentalFeatures.ts b/packages/core/test/emulate/mockExperimentalFeatures.ts
deleted file mode 100644
index 0a1f003028..0000000000
--- a/packages/core/test/emulate/mockExperimentalFeatures.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import {
- resetExperimentalFeatures,
- type ExperimentalFeature,
- addExperimentalFeatures,
-} from '../../src/tools/experimentalFeatures'
-import { registerCleanupTask } from '../registerCleanupTask'
-
-export function mockExperimentalFeatures(enabledFeatures: ExperimentalFeature[]) {
- addExperimentalFeatures(enabledFeatures)
- registerCleanupTask(resetExperimentalFeatures)
-}
diff --git a/packages/core/test/forEach.spec.ts b/packages/core/test/forEach.spec.ts
index 685b3810e0..26be9d3b9b 100644
--- a/packages/core/test/forEach.spec.ts
+++ b/packages/core/test/forEach.spec.ts
@@ -1,3 +1,4 @@
+import { resetExperimentalFeatures } from '../src/tools/experimentalFeatures'
import { resetValueHistoryGlobals } from '../src/tools/valueHistory'
import { resetFetchObservable } from '../src/browser/fetchObservable'
import { resetConsoleObservable } from '../src/domain/console/consoleObservable'
@@ -35,6 +36,7 @@ afterEach(() => {
resetMonitor()
resetTelemetry()
resetInteractionCountPolyfill()
+ resetExperimentalFeatures()
})
function clearAllCookies() {
diff --git a/packages/core/test/index.ts b/packages/core/test/index.ts
index 16322a6105..ab898056e9 100644
--- a/packages/core/test/index.ts
+++ b/packages/core/test/index.ts
@@ -15,7 +15,6 @@ export * from './emulate/mockVisibilityState'
export * from './emulate/mockNavigator'
export * from './emulate/mockEventBridge'
export * from './emulate/mockFlushController'
-export * from './emulate/mockExperimentalFeatures'
export * from './emulate/mockFetch'
export * from './emulate/mockXhr'
export * from './emulate/mockEventTarget'
diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts
index 875e9afc91..7f94bbc124 100644
--- a/packages/rum-core/src/boot/preStartRum.spec.ts
+++ b/packages/rum-core/src/boot/preStartRum.spec.ts
@@ -10,6 +10,7 @@ import {
DefaultPrivacyLevel,
ExperimentalFeature,
startTelemetry,
+ addExperimentalFeatures,
} from '@datadog/browser-core'
import type { Clock } from '@datadog/browser-core/test'
import {
@@ -18,7 +19,6 @@ import {
mockClock,
mockEventBridge,
mockSyntheticsWorkerValues,
- mockExperimentalFeatures,
createFakeTelemetryObject,
replaceMockableWithSpy,
} from '@datadog/browser-core/test'
@@ -686,7 +686,7 @@ describe('preStartRum', () => {
})
it('startAction / stopAction', () => {
- mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION])
+ addExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION])
const startActionSpy = jasmine.createSpy()
const stopActionSpy = jasmine.createSpy()
diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts
index bb85c4cd84..b409237f0d 100644
--- a/packages/rum-core/src/boot/rumPublicApi.spec.ts
+++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts
@@ -7,14 +7,10 @@ import {
ExperimentalFeature,
ResourceType,
startTelemetry,
+ addExperimentalFeatures,
} from '@datadog/browser-core'
import type { Clock } from '@datadog/browser-core/test'
-import {
- createFakeTelemetryObject,
- mockClock,
- mockExperimentalFeatures,
- replaceMockableWithSpy,
-} from '@datadog/browser-core/test'
+import { createFakeTelemetryObject, mockClock, replaceMockableWithSpy } from '@datadog/browser-core/test'
import { noopRecorderApi, noopProfilerApi } from '../../test'
import { ActionType, VitalType } from '../rawRumEvent.types'
import type { DurationVitalReference } from '../domain/vital/vitalCollection'
@@ -742,7 +738,7 @@ describe('rum public api', () => {
describe('startAction / stopAction', () => {
it('should call startAction and stopAction on the strategy', () => {
- mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION])
+ addExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION])
const startActionSpy = jasmine.createSpy()
const stopActionSpy = jasmine.createSpy()
@@ -778,7 +774,7 @@ describe('rum public api', () => {
})
it('should sanitize startAction and stopAction inputs', () => {
- mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION])
+ addExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION])
const startActionSpy = jasmine.createSpy()
const { rumPublicApi } = makeRumPublicApiWithDefaults({
@@ -824,7 +820,7 @@ describe('rum public api', () => {
describe('startResource / stopResource', () => {
it('should call startResource and stopResource on the strategy', () => {
- mockExperimentalFeatures([ExperimentalFeature.START_STOP_RESOURCE])
+ addExperimentalFeatures([ExperimentalFeature.START_STOP_RESOURCE])
const startResourceSpy = jasmine.createSpy()
const stopResourceSpy = jasmine.createSpy()
@@ -864,7 +860,7 @@ describe('rum public api', () => {
})
it('should sanitize startResource and stopResource inputs', () => {
- mockExperimentalFeatures([ExperimentalFeature.START_STOP_RESOURCE])
+ addExperimentalFeatures([ExperimentalFeature.START_STOP_RESOURCE])
const startResourceSpy = jasmine.createSpy()
const { rumPublicApi } = makeRumPublicApiWithDefaults({
diff --git a/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts b/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts
index 3def053547..98f20421f2 100644
--- a/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts
+++ b/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts
@@ -1,5 +1,4 @@
-import { ExperimentalFeature } from '@datadog/browser-core'
-import { mockExperimentalFeatures } from '../../../../core/test'
+import { addExperimentalFeatures, ExperimentalFeature } from '@datadog/browser-core'
import { appendElement, mockRumConfiguration } from '../../../test'
import { NodePrivacyLevel } from '../privacyConstants'
import { getNodeSelfPrivacyLevel } from '../privacy'
@@ -115,7 +114,7 @@ describe('getActionNameFromElement', () => {
})
it('should introduce whitespace for block-level display values', () => {
- mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
+ addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
const testCases = [
{ display: 'block', expected: 'space' },
{ display: 'inline-block', expected: 'no-space' },
@@ -484,7 +483,7 @@ describe('getActionNameFromElement', () => {
})
it('removes only the child with programmatic action name in textual content', () => {
- mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
+ addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
const { name, nameSource } = getActionNameFromElement(
appendElement('
Foobar Baz
bar
'),
defaultConfiguration
@@ -512,7 +511,7 @@ describe('getActionNameFromElement', () => {
}
it('preserves privacy level of the element when defaultPrivacyLevel is mask-unless-allowlisted', () => {
- mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
+ addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
const { name, nameSource } = getActionNameFromElement(
appendElement(`
@@ -666,7 +665,7 @@ describe('getActionNameFromElement', () => {
},
]
testCases.forEach(({ html, defaultPrivacyLevel, allowlist, expectedName, expectedNameSource }) => {
- mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
+ addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
;(window as BrowserWindow).$DD_ALLOW = new Set(allowlist)
const target = appendElement(html)
const { name, nameSource } = getActionNameFromElement(
@@ -883,7 +882,7 @@ describe('getActionNameFromElement', () => {
})
it('inherit privacy level and remove only the masked child', () => {
- mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
+ addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
expect(
getActionNameFromElement(
appendElement(`
diff --git a/packages/rum-core/src/domain/action/trackClickActions.spec.ts b/packages/rum-core/src/domain/action/trackClickActions.spec.ts
index da0cd6b6f9..da4014a6f3 100644
--- a/packages/rum-core/src/domain/action/trackClickActions.spec.ts
+++ b/packages/rum-core/src/domain/action/trackClickActions.spec.ts
@@ -8,9 +8,10 @@ import {
Observable,
ExperimentalFeature,
PageExitReason,
+ addExperimentalFeatures,
} from '@datadog/browser-core'
import type { Clock } from '@datadog/browser-core/test'
-import { createNewEvent, mockClock, mockExperimentalFeatures } from '@datadog/browser-core/test'
+import { createNewEvent, mockClock } from '@datadog/browser-core/test'
import { createFakeClick, createMutationRecord, mockRumConfiguration } from '../../../test'
import type { AssembledRumEvent } from '../../rawRumEvent.types'
import { RumEventType, ActionType, FrustrationType } from '../../rawRumEvent.types'
@@ -478,7 +479,7 @@ describe('trackClickActions', () => {
})
it('should mask action name when defaultPrivacyLevel is mask_unless_allowlisted and not in allowlist', () => {
- mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
+ addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
startClickActionsTracking({
defaultPrivacyLevel: DefaultPrivacyLevel.MASK_UNLESS_ALLOWLISTED,
enablePrivacyForActionName: true,
@@ -523,7 +524,7 @@ describe('trackClickActions', () => {
})
it('should use allowlist masking when defaultPrivacyLevel is allow and node privacy level is mask-unless-allowlisted', () => {
- mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
+ addExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])
button.setAttribute('data-dd-privacy', 'mask-unless-allowlisted')
startClickActionsTracking({
defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW,
diff --git a/packages/rum-core/src/domain/action/trackManualActions.spec.ts b/packages/rum-core/src/domain/action/trackManualActions.spec.ts
index 58235059c5..bf69126453 100644
--- a/packages/rum-core/src/domain/action/trackManualActions.spec.ts
+++ b/packages/rum-core/src/domain/action/trackManualActions.spec.ts
@@ -1,7 +1,7 @@
import type { Duration, ServerDuration } from '@datadog/browser-core'
-import { ExperimentalFeature, Observable } from '@datadog/browser-core'
+import { addExperimentalFeatures, ExperimentalFeature, Observable } from '@datadog/browser-core'
import type { Clock } from '@datadog/browser-core/test'
-import { mockClock, mockExperimentalFeatures, registerCleanupTask } from '@datadog/browser-core/test'
+import { mockClock, registerCleanupTask } from '@datadog/browser-core/test'
import { collectAndValidateRawRumEvents, mockRumConfiguration } from '../../../test'
import type { RawRumActionEvent, RawRumEvent } from '../../rawRumEvent.types'
import { RumEventType, ActionType, FrustrationType } from '../../rawRumEvent.types'
@@ -21,7 +21,7 @@ describe('trackManualActions', () => {
beforeEach(() => {
clock = mockClock()
- mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION])
+ addExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION])
const domMutationObservable = new Observable
()
const windowOpenObservable = new Observable()
diff --git a/packages/rum-core/src/domain/contexts/sourceCodeContext.spec.ts b/packages/rum-core/src/domain/contexts/sourceCodeContext.spec.ts
index 2c7c0b3d7e..b7336b4119 100644
--- a/packages/rum-core/src/domain/contexts/sourceCodeContext.spec.ts
+++ b/packages/rum-core/src/domain/contexts/sourceCodeContext.spec.ts
@@ -1,8 +1,8 @@
-import { ExperimentalFeature, HookNames } from '@datadog/browser-core'
+import { addExperimentalFeatures, ExperimentalFeature, HookNames } from '@datadog/browser-core'
import type { RelativeTime } from '@datadog/browser-core'
import type { AssembleHookParams, Hooks } from '../hooks'
import { createHooks } from '../hooks'
-import { mockExperimentalFeatures, registerCleanupTask } from '../../../../core/test'
+import { registerCleanupTask } from '../../../../core/test'
import type { RawRumLongAnimationFrameEvent } from '../../rawRumEvent.types'
import type { BrowserWindow } from './sourceCodeContext'
import { startSourceCodeContext } from './sourceCodeContext'
@@ -59,7 +59,7 @@ describe('sourceCodeContext', () => {
describe('assemble hook when FF enabled', () => {
beforeEach(() => {
- mockExperimentalFeatures([ExperimentalFeature.SOURCE_CODE_CONTEXT])
+ addExperimentalFeatures([ExperimentalFeature.SOURCE_CODE_CONTEXT])
})
it('should add source code context matching the error stack first frame URL', () => {
diff --git a/packages/rum-core/src/domain/resource/resourceUtils.spec.ts b/packages/rum-core/src/domain/resource/resourceUtils.spec.ts
index 65623b9032..2e53855db0 100644
--- a/packages/rum-core/src/domain/resource/resourceUtils.spec.ts
+++ b/packages/rum-core/src/domain/resource/resourceUtils.spec.ts
@@ -1,6 +1,5 @@
-import { type Duration, type RelativeTime, type ServerDuration } from '@datadog/browser-core'
+import { addExperimentalFeatures, type Duration, type RelativeTime, type ServerDuration } from '@datadog/browser-core'
import { ExperimentalFeature } from '@datadog/browser-core'
-import { mockExperimentalFeatures } from '@datadog/browser-core/test'
import { RumPerformanceEntryType, type RumPerformanceResourceTiming } from '../../browser/performanceObservable'
import {
MAX_RESOURCE_VALUE_CHAR_LENGTH,
@@ -296,7 +295,7 @@ describe('shouldTrackResource', () => {
})
it('should allow requests on intake endpoints when TRACK_INTAKE_REQUESTS is enabled', () => {
- mockExperimentalFeatures([ExperimentalFeature.TRACK_INTAKE_REQUESTS])
+ addExperimentalFeatures([ExperimentalFeature.TRACK_INTAKE_REQUESTS])
expect(isAllowedRequestUrl(`https://rum-intake.com/v1/input/abcde?${intakeParameters}`)).toBe(true)
})
diff --git a/packages/rum-core/src/domain/startCustomerDataTelemetry.spec.ts b/packages/rum-core/src/domain/startCustomerDataTelemetry.spec.ts
index 7aa84901e1..5c35c3019e 100644
--- a/packages/rum-core/src/domain/startCustomerDataTelemetry.spec.ts
+++ b/packages/rum-core/src/domain/startCustomerDataTelemetry.spec.ts
@@ -1,5 +1,5 @@
import type { FlushEvent, Context, Telemetry } from '@datadog/browser-core'
-import { Observable, resetExperimentalFeatures } from '@datadog/browser-core'
+import { Observable } from '@datadog/browser-core'
import type { Clock, MockTelemetry } from '@datadog/browser-core/test'
import { mockClock, startMockTelemetry } from '@datadog/browser-core/test'
import type { AssembledRumEvent } from '../rawRumEvent.types'
@@ -52,10 +52,6 @@ describe('customerDataTelemetry', () => {
clock = mockClock()
})
- afterEach(() => {
- resetExperimentalFeatures()
- })
-
it('should collect customer data telemetry', async () => {
setupCustomerTelemetryCollection()
diff --git a/packages/rum-core/src/domain/view/trackViews.spec.ts b/packages/rum-core/src/domain/view/trackViews.spec.ts
index d8a57aa688..50a29d3c35 100644
--- a/packages/rum-core/src/domain/view/trackViews.spec.ts
+++ b/packages/rum-core/src/domain/view/trackViews.spec.ts
@@ -5,7 +5,6 @@ import {
display,
relativeToClocks,
relativeNow,
- resetExperimentalFeatures,
addExperimentalFeatures,
ExperimentalFeature,
} from '@datadog/browser-core'
@@ -432,7 +431,6 @@ describe('view metrics', () => {
beforeEach(() => {
addExperimentalFeatures([ExperimentalFeature.LCP_SUBPARTS])
- registerCleanupTask(resetExperimentalFeatures)
clock = mockClock()
;({ notifyPerformanceEntries } = mockPerformanceObserver())
@@ -899,7 +897,6 @@ describe('view event count', () => {
registerCleanupTask(() => {
viewTest.stop()
- resetExperimentalFeatures()
})
})
@@ -1057,7 +1054,6 @@ describe('service and version', () => {
registerCleanupTask(() => {
viewTest.stop()
- resetExperimentalFeatures()
})
})
diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackCumulativeLayoutShift.spec.ts b/packages/rum-core/src/domain/view/viewMetrics/trackCumulativeLayoutShift.spec.ts
index aff6b0f2b7..8d03c0fa57 100644
--- a/packages/rum-core/src/domain/view/viewMetrics/trackCumulativeLayoutShift.spec.ts
+++ b/packages/rum-core/src/domain/view/viewMetrics/trackCumulativeLayoutShift.spec.ts
@@ -1,6 +1,6 @@
import type { RelativeTime } from '@datadog/browser-core'
import { registerCleanupTask } from '@datadog/browser-core/test'
-import { resetExperimentalFeatures, elapsed, ONE_SECOND } from '@datadog/browser-core'
+import { elapsed, ONE_SECOND } from '@datadog/browser-core'
import {
appendElement,
appendText,
@@ -238,10 +238,6 @@ describe('trackCumulativeLayoutShift', () => {
})
describe('cls target element', () => {
- afterEach(() => {
- resetExperimentalFeatures()
- })
-
it('should return the first target element selector amongst all the shifted nodes', () => {
startCLSTracking()
const textNode = appendText('text')
diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts b/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts
index 5378482009..b90fc4e94f 100644
--- a/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts
+++ b/packages/rum-core/src/domain/view/viewMetrics/trackInteractionToNextPaint.spec.ts
@@ -1,11 +1,5 @@
import type { Duration, RelativeTime } from '@datadog/browser-core'
-import {
- elapsed,
- relativeNow,
- resetExperimentalFeatures,
- ExperimentalFeature,
- addExperimentalFeatures,
-} from '@datadog/browser-core'
+import { elapsed, relativeNow, ExperimentalFeature, addExperimentalFeatures } from '@datadog/browser-core'
import { registerCleanupTask } from '@datadog/browser-core/test'
import {
appendElement,
@@ -59,7 +53,6 @@ describe('trackInteractionToNextPaint', () => {
registerCleanupTask(() => {
interactionToNextPaintTracking.stop()
- resetExperimentalFeatures()
interactionCountMock.clear()
})
}
diff --git a/packages/rum-core/src/domain/view/viewMetrics/trackLargestContentfulPaint.spec.ts b/packages/rum-core/src/domain/view/viewMetrics/trackLargestContentfulPaint.spec.ts
index 9cab3e8543..db9537f95e 100644
--- a/packages/rum-core/src/domain/view/viewMetrics/trackLargestContentfulPaint.spec.ts
+++ b/packages/rum-core/src/domain/view/viewMetrics/trackLargestContentfulPaint.spec.ts
@@ -1,11 +1,5 @@
import type { RelativeTime } from '@datadog/browser-core'
-import {
- clocksOrigin,
- DOM_EVENT,
- ExperimentalFeature,
- addExperimentalFeatures,
- resetExperimentalFeatures,
-} from '@datadog/browser-core'
+import { clocksOrigin, DOM_EVENT, ExperimentalFeature, addExperimentalFeatures } from '@datadog/browser-core'
import type { Clock } from '@datadog/browser-core/test'
import {
setPageVisibility,
@@ -86,7 +80,6 @@ describe('trackLargestContentfulPaint', () => {
beforeEach(() => {
addExperimentalFeatures([ExperimentalFeature.LCP_SUBPARTS])
- registerCleanupTask(resetExperimentalFeatures)
lcpCallback = jasmine.createSpy()
eventTarget = document.createElement('div') as unknown as Window
diff --git a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts
index 302c5ae01a..c962af3caa 100644
--- a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts
+++ b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts
@@ -1,6 +1,6 @@
import type { Duration } from '@datadog/browser-core'
-import { mockClock, mockExperimentalFeatures, type Clock } from '@datadog/browser-core/test'
-import { clocksNow, ExperimentalFeature } from '@datadog/browser-core'
+import { mockClock, type Clock } from '@datadog/browser-core/test'
+import { addExperimentalFeatures, clocksNow, ExperimentalFeature } from '@datadog/browser-core'
import { collectAndValidateRawRumEvents, mockPageStateHistory } from '../../../test'
import type { RawRumEvent, RawRumVitalEvent } from '../../rawRumEvent.types'
import { VitalType, RumEventType } from '../../rawRumEvent.types'
@@ -236,7 +236,7 @@ describe('vitalCollection', () => {
})
it('should collect raw rum event from operation step vital', () => {
- mockExperimentalFeatures([ExperimentalFeature.FEATURE_OPERATION_VITAL])
+ addExperimentalFeatures([ExperimentalFeature.FEATURE_OPERATION_VITAL])
vitalCollection.addOperationStepVital('foo', 'start')
expect(rawRumEvents[0].startClocks.relative).toEqual(jasmine.any(Number))
@@ -274,7 +274,7 @@ describe('vitalCollection', () => {
})
it('should create a operation step vital from add API', () => {
- mockExperimentalFeatures([ExperimentalFeature.FEATURE_OPERATION_VITAL])
+ addExperimentalFeatures([ExperimentalFeature.FEATURE_OPERATION_VITAL])
vitalCollection.addOperationStepVital(
'foo',
'end',
diff --git a/packages/rum/src/boot/startRecording.spec.ts b/packages/rum/src/boot/startRecording.spec.ts
index df715486af..f8ba823328 100644
--- a/packages/rum/src/boot/startRecording.spec.ts
+++ b/packages/rum/src/boot/startRecording.spec.ts
@@ -6,16 +6,11 @@ import {
DeflateEncoderStreamId,
Observable,
ExperimentalFeature,
+ addExperimentalFeatures,
} from '@datadog/browser-core'
import type { ViewCreatedEvent } from '@datadog/browser-rum-core'
import { LifeCycle, LifeCycleEventType, startViewHistory } from '@datadog/browser-rum-core'
-import {
- collectAsyncCalls,
- createNewEvent,
- mockEventBridge,
- mockExperimentalFeatures,
- registerCleanupTask,
-} from '@datadog/browser-core/test'
+import { collectAsyncCalls, createNewEvent, mockEventBridge, registerCleanupTask } from '@datadog/browser-core/test'
import type { ViewEndedEvent } from 'packages/rum-core/src/domain/view/trackViews'
import type { RumSessionManagerMock } from '../../../rum-core/test'
import { appendElement, createRumSessionManagerMock, mockRumConfiguration } from '../../../rum-core/test'
@@ -110,7 +105,7 @@ describe('startRecording', () => {
})
it('sends recorded segments with valid context when Change records are enabled', async () => {
- mockExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS])
+ addExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS])
setupStartRecording()
flushSegment(lifeCycle)
diff --git a/packages/rum/src/domain/record/assembly.ts b/packages/rum/src/domain/record/assembly.ts
index 747d467a3e..ec76affda4 100644
--- a/packages/rum/src/domain/record/assembly.ts
+++ b/packages/rum/src/domain/record/assembly.ts
@@ -1,10 +1,12 @@
+import type { TimeStamp } from '@datadog/browser-core'
import { timeStampNow } from '@datadog/browser-core'
import type { BrowserIncrementalData, BrowserIncrementalSnapshotRecord } from '../../types'
import { RecordType } from '../../types'
export function assembleIncrementalSnapshot(
source: Data['source'],
- data: Omit
+ data: Omit,
+ timestamp: TimeStamp = timeStampNow()
): BrowserIncrementalSnapshotRecord {
return {
data: {
@@ -12,6 +14,6 @@ export function assembleIncrementalSnapshot
...data,
} as Data,
type: RecordType.IncrementalSnapshot,
- timestamp: timeStampNow(),
+ timestamp,
}
}
diff --git a/packages/rum/src/domain/record/index.ts b/packages/rum/src/domain/record/index.ts
index 4fd827cea3..aa832d2930 100644
--- a/packages/rum/src/domain/record/index.ts
+++ b/packages/rum/src/domain/record/index.ts
@@ -2,6 +2,6 @@ export { takeFullSnapshot, takeNodeSnapshot } from './internalApi'
export { record } from './record'
export type { SerializationMetric, SerializationStats } from './serialization'
export { createSerializationStats, aggregateSerializationStats } from './serialization'
-export { serializeNode, serializeDocument } from './serialization'
+export { serializeNode } from './serialization'
export { createElementsScrollPositions } from './elementsScrollPositions'
export type { ShadowRootsController } from './shadowRootsController'
diff --git a/packages/rum/src/domain/record/serialization/index.ts b/packages/rum/src/domain/record/serialization/index.ts
index d3892ab490..789f2ae647 100644
--- a/packages/rum/src/domain/record/serialization/index.ts
+++ b/packages/rum/src/domain/record/serialization/index.ts
@@ -1,6 +1,8 @@
export { createRootInsertionCursor } from './insertionCursor'
export { getElementInputValue } from './serializationUtils'
-export { serializeDocument } from './serializeDocument'
+export { serializeFullSnapshot } from './serializeFullSnapshot'
+export { serializeFullSnapshotAsChange } from './serializeFullSnapshotAsChange'
+export { serializeMutations } from './serializeMutations'
export { serializeNode } from './serializeNode'
export { serializeNodeAsChange } from './serializeNodeAsChange'
export { serializeAttribute } from './serializeAttribute'
diff --git a/packages/rum/src/domain/record/serialization/serializeDocument.ts b/packages/rum/src/domain/record/serialization/serializeDocument.ts
deleted file mode 100644
index 4b8f19a81e..0000000000
--- a/packages/rum/src/domain/record/serialization/serializeDocument.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { DocumentNode, SerializedNodeWithId } from '../../../types'
-import { serializeNode } from './serializeNode'
-import type { SerializationTransaction } from './serializationTransaction'
-
-export function serializeDocument(
- document: Document,
- transaction: SerializationTransaction
-): DocumentNode & SerializedNodeWithId {
- const defaultPrivacyLevel = transaction.scope.configuration.defaultPrivacyLevel
- const serializedNode = serializeNode(document, defaultPrivacyLevel, transaction)
-
- // We are sure that Documents are never ignored, so this function never returns null
- return serializedNode as DocumentNode & SerializedNodeWithId
-}
diff --git a/packages/rum/src/domain/record/serialization/serializeFullSnapshot.ts b/packages/rum/src/domain/record/serialization/serializeFullSnapshot.ts
new file mode 100644
index 0000000000..598da499af
--- /dev/null
+++ b/packages/rum/src/domain/record/serialization/serializeFullSnapshot.ts
@@ -0,0 +1,38 @@
+import { getScrollX, getScrollY } from '@datadog/browser-rum-core'
+import type { TimeStamp } from '@datadog/browser-core'
+import type { BrowserFullSnapshotRecord } from '../../../types'
+import { RecordType } from '../../../types'
+import type { EmitRecordCallback, EmitStatsCallback } from '../record.types'
+import type { RecordingScope } from '../recordingScope'
+import { serializeNode } from './serializeNode'
+import { serializeInTransaction } from './serializationTransaction'
+import type { SerializationKind, SerializationTransaction } from './serializationTransaction'
+
+export function serializeFullSnapshot(
+ timestamp: TimeStamp,
+ kind: SerializationKind,
+ document: Document,
+ emitRecord: EmitRecordCallback,
+ emitStats: EmitStatsCallback,
+ scope: RecordingScope
+): void {
+ serializeInTransaction(kind, emitRecord, emitStats, scope, (transaction: SerializationTransaction) => {
+ const defaultPrivacyLevel = transaction.scope.configuration.defaultPrivacyLevel
+
+ // We are sure that Documents are never ignored, so this function never returns null.
+ const node = serializeNode(document, defaultPrivacyLevel, transaction)!
+
+ const record: BrowserFullSnapshotRecord = {
+ data: {
+ node,
+ initialOffset: {
+ left: getScrollX(),
+ top: getScrollY(),
+ },
+ },
+ type: RecordType.FullSnapshot,
+ timestamp,
+ }
+ transaction.add(record)
+ })
+}
diff --git a/packages/rum/src/domain/record/serialization/serializeFullSnapshotAsChange.ts b/packages/rum/src/domain/record/serialization/serializeFullSnapshotAsChange.ts
new file mode 100644
index 0000000000..ff04ff6324
--- /dev/null
+++ b/packages/rum/src/domain/record/serialization/serializeFullSnapshotAsChange.ts
@@ -0,0 +1,33 @@
+import type { TimeStamp } from '@datadog/browser-core'
+import type { EmitRecordCallback, EmitStatsCallback } from '../record.types'
+import type { RecordingScope } from '../recordingScope'
+import { serializeChangesInTransaction } from './serializationTransaction'
+import type { ChangeSerializationTransaction, SerializationKind } from './serializationTransaction'
+import { serializeNodeAsChange } from './serializeNodeAsChange'
+import { createRootInsertionCursor } from './insertionCursor'
+
+export function serializeFullSnapshotAsChange(
+ timestamp: TimeStamp,
+ kind: SerializationKind,
+ document: Document,
+ emitRecord: EmitRecordCallback,
+ emitStats: EmitStatsCallback,
+ scope: RecordingScope
+): void {
+ scope.resetIds()
+ serializeChangesInTransaction(
+ kind,
+ emitRecord,
+ emitStats,
+ scope,
+ timestamp,
+ (transaction: ChangeSerializationTransaction) => {
+ serializeNodeAsChange(
+ createRootInsertionCursor(scope.nodeIds),
+ document,
+ scope.configuration.defaultPrivacyLevel,
+ transaction
+ )
+ }
+ )
+}
diff --git a/packages/rum/src/domain/record/serialization/serializeMutations.spec.ts b/packages/rum/src/domain/record/serialization/serializeMutations.spec.ts
new file mode 100644
index 0000000000..8d44b2d363
--- /dev/null
+++ b/packages/rum/src/domain/record/serialization/serializeMutations.spec.ts
@@ -0,0 +1,113 @@
+import type { RecordingScope } from '../recordingScope'
+import { createRecordingScopeForTesting } from '../test/recordingScope.specHelper'
+import { idsAreAssignedForNodeAndAncestors, sortAddedAndMovedNodes } from './serializeMutations'
+
+describe('idsAreAssignedForNodeAndAncestors', () => {
+ let scope: RecordingScope
+
+ beforeEach(() => {
+ scope = createRecordingScopeForTesting()
+ })
+
+ it('returns false for DOM Nodes that have not been assigned an id', () => {
+ expect(idsAreAssignedForNodeAndAncestors(document.createElement('div'), scope.nodeIds)).toBe(false)
+ })
+
+ it('returns true for DOM Nodes that have been assigned an id', () => {
+ const node = document.createElement('div')
+ scope.nodeIds.getOrInsert(node)
+ expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true)
+ })
+
+ it('returns false for DOM Nodes when an ancestor has not been assigned an id', () => {
+ const node = document.createElement('div')
+ scope.nodeIds.getOrInsert(node)
+
+ const parent = document.createElement('div')
+ parent.appendChild(node)
+ scope.nodeIds.getOrInsert(parent)
+
+ const grandparent = document.createElement('div')
+ grandparent.appendChild(parent)
+
+ expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(false)
+ })
+
+ it('returns true for DOM Nodes when all ancestors have been assigned an id', () => {
+ const node = document.createElement('div')
+ scope.nodeIds.getOrInsert(node)
+
+ const parent = document.createElement('div')
+ parent.appendChild(node)
+ scope.nodeIds.getOrInsert(parent)
+
+ const grandparent = document.createElement('div')
+ grandparent.appendChild(parent)
+ scope.nodeIds.getOrInsert(grandparent)
+
+ expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true)
+ })
+
+ it('returns true for DOM Nodes in shadow subtrees', () => {
+ const node = document.createElement('div')
+ scope.nodeIds.getOrInsert(node)
+
+ const parent = document.createElement('div')
+ parent.appendChild(node)
+ scope.nodeIds.getOrInsert(parent)
+
+ const grandparent = document.createElement('div')
+ const shadowRoot = grandparent.attachShadow({ mode: 'open' })
+ shadowRoot.appendChild(parent)
+ scope.nodeIds.getOrInsert(grandparent)
+
+ expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true)
+ })
+})
+
+describe('sortAddedAndMovedNodes', () => {
+ let parent: Node
+ let a: Node
+ let aa: Node
+ let b: Node
+ let c: Node
+ let d: Node
+
+ beforeEach(() => {
+ // Create a tree like this:
+ // parent
+ // / | \ \
+ // a b c d
+ // |
+ // aa
+ a = document.createElement('a')
+ aa = document.createElement('aa')
+ b = document.createElement('b')
+ c = document.createElement('c')
+ d = document.createElement('d')
+ parent = document.createElement('parent')
+ parent.appendChild(a)
+ a.appendChild(aa)
+ parent.appendChild(b)
+ parent.appendChild(c)
+ parent.appendChild(d)
+ })
+
+ it('sorts siblings in reverse order', () => {
+ const nodes = [c, b, d, a]
+ sortAddedAndMovedNodes(nodes)
+ expect(nodes).toEqual([d, c, b, a])
+ })
+
+ it('sorts parents', () => {
+ const nodes = [a, parent, aa]
+ sortAddedAndMovedNodes(nodes)
+ expect(nodes).toEqual([parent, a, aa])
+ })
+
+ it('sorts parents first then siblings', () => {
+ const nodes = [c, aa, b, parent, d, a]
+ sortAddedAndMovedNodes(nodes)
+ expect(nodes).toEqual([parent, d, c, b, a, aa])
+ })
+})
diff --git a/packages/rum/src/domain/record/serialization/serializeMutations.ts b/packages/rum/src/domain/record/serialization/serializeMutations.ts
new file mode 100644
index 0000000000..284db5ba25
--- /dev/null
+++ b/packages/rum/src/domain/record/serialization/serializeMutations.ts
@@ -0,0 +1,388 @@
+import type {
+ NodePrivacyLevelCache,
+ RumMutationRecord,
+ RumChildListMutationRecord,
+ RumCharacterDataMutationRecord,
+ RumAttributesMutationRecord,
+} from '@datadog/browser-rum-core'
+import {
+ isNodeShadowHost,
+ getParentNode,
+ forEachChildNodes,
+ getNodePrivacyLevel,
+ getTextContent,
+ NodePrivacyLevel,
+ isNodeShadowRoot,
+} from '@datadog/browser-rum-core'
+import type { TimeStamp } from '@datadog/browser-core'
+import { IncrementalSource } from '../../../types'
+import type {
+ BrowserMutationData,
+ AddedNodeMutation,
+ AttributeMutation,
+ RemovedNodeMutation,
+ TextMutation,
+} from '../../../types'
+import type { RecordingScope } from '../recordingScope'
+import type { RemoveShadowRootCallBack } from '../shadowRootsController'
+import { assembleIncrementalSnapshot } from '../assembly'
+import type { EmitRecordCallback, EmitStatsCallback } from '../record.types'
+import type { NodeId, NodeIds } from '../itemIds'
+import type { SerializationTransaction } from './serializationTransaction'
+import { SerializationKind, serializeInTransaction } from './serializationTransaction'
+import { serializeNode } from './serializeNode'
+import { serializeAttribute } from './serializeAttribute'
+import { getElementInputValue } from './serializationUtils'
+
+export type NodeWithSerializedNode = Node & { __brand: 'NodeWithSerializedNode' }
+type WithSerializedTarget = T & { target: NodeWithSerializedNode }
+
+export function serializeMutations(
+ timestamp: TimeStamp,
+ mutations: RumMutationRecord[],
+ emitRecord: EmitRecordCallback,
+ emitStats: EmitStatsCallback,
+ scope: RecordingScope
+): void {
+ serializeInTransaction(
+ SerializationKind.INCREMENTAL_SNAPSHOT,
+ emitRecord,
+ emitStats,
+ scope,
+ (transaction: SerializationTransaction) => processMutations(timestamp, mutations, transaction)
+ )
+}
+
+function processMutations(
+ timestamp: TimeStamp,
+ mutations: RumMutationRecord[],
+ transaction: SerializationTransaction
+): void {
+ const nodePrivacyLevelCache: NodePrivacyLevelCache = new Map()
+
+ mutations
+ .filter((mutation): mutation is RumChildListMutationRecord => mutation.type === 'childList')
+ .forEach((mutation) => {
+ mutation.removedNodes.forEach((removedNode) => {
+ traverseRemovedShadowDom(removedNode, transaction.scope.shadowRootsController.removeShadowRoot)
+ })
+ })
+
+ // Discard any mutation with a 'target' node that:
+ // * isn't injected in the current document or isn't known/serialized yet: those nodes are likely
+ // part of a mutation occurring in a parent Node
+ // * should be hidden or ignored
+ const filteredMutations = mutations.filter(
+ (mutation): mutation is WithSerializedTarget =>
+ mutation.target.isConnected &&
+ idsAreAssignedForNodeAndAncestors(mutation.target, transaction.scope.nodeIds) &&
+ getNodePrivacyLevel(
+ mutation.target,
+ transaction.scope.configuration.defaultPrivacyLevel,
+ nodePrivacyLevelCache
+ ) !== NodePrivacyLevel.HIDDEN
+ )
+
+ const { adds, removes, hasBeenSerialized } = processChildListMutations(
+ filteredMutations.filter(
+ (mutation): mutation is WithSerializedTarget => mutation.type === 'childList'
+ ),
+ nodePrivacyLevelCache,
+ transaction
+ )
+
+ const texts = processCharacterDataMutations(
+ filteredMutations.filter(
+ (mutation): mutation is WithSerializedTarget =>
+ mutation.type === 'characterData' && !hasBeenSerialized(mutation.target)
+ ),
+ nodePrivacyLevelCache,
+ transaction
+ )
+
+ const attributes = processAttributesMutations(
+ filteredMutations.filter(
+ (mutation): mutation is WithSerializedTarget =>
+ mutation.type === 'attributes' && !hasBeenSerialized(mutation.target)
+ ),
+ nodePrivacyLevelCache,
+ transaction
+ )
+
+ if (!texts.length && !attributes.length && !removes.length && !adds.length) {
+ return
+ }
+
+ const record = assembleIncrementalSnapshot(
+ IncrementalSource.Mutation,
+ {
+ adds,
+ removes,
+ texts,
+ attributes,
+ },
+ timestamp
+ )
+ transaction.add(record)
+}
+
+function processChildListMutations(
+ mutations: Array>,
+ nodePrivacyLevelCache: NodePrivacyLevelCache,
+ transaction: SerializationTransaction
+) {
+ // First, we iterate over mutations to collect:
+ //
+ // * nodes that have been added in the document and not removed by a subsequent mutation
+ // * nodes that have been removed from the document but were not added in a previous mutation
+ //
+ // For this second category, we also collect their previous parent (mutation.target) because we'll
+ // need it to emit a 'remove' mutation.
+ //
+ // Those two categories may overlap: if a node moved from a position to another, it is reported as
+ // two mutation records, one with a "removedNodes" and the other with "addedNodes". In this case,
+ // the node will be in both sets.
+ const addedAndMovedNodes = new Set()
+ const removedNodes = new Map()
+ for (const mutation of mutations) {
+ mutation.addedNodes.forEach((node) => {
+ addedAndMovedNodes.add(node)
+ })
+ mutation.removedNodes.forEach((node) => {
+ if (!addedAndMovedNodes.has(node)) {
+ removedNodes.set(node, mutation.target)
+ }
+ addedAndMovedNodes.delete(node)
+ })
+ }
+
+ // Then, we sort nodes that are still in the document by topological order, for two reasons:
+ //
+ // * We will serialize each added nodes with their descendants. We don't want to serialize a node
+ // twice, so we need to iterate over the parent nodes first and skip any node that is contained in
+ // a precedent node.
+ //
+ // * To emit "add" mutations, we need references to the parent and potential next sibling of each
+ // added node. So we need to iterate over the parent nodes first, and when multiple nodes are
+ // siblings, we want to iterate from last to first. This will ensure that any "next" node is
+ // already serialized and have an id.
+ const sortedAddedAndMovedNodes = Array.from(addedAndMovedNodes)
+ sortAddedAndMovedNodes(sortedAddedAndMovedNodes)
+
+ // Then, we iterate over our sorted node sets to emit mutations. We collect the newly serialized
+ // node ids in a set to be able to skip subsequent related mutations.
+ transaction.serializedNodeIds = new Set()
+
+ const addedNodeMutations: AddedNodeMutation[] = []
+ for (const node of sortedAddedAndMovedNodes) {
+ if (hasBeenSerialized(node)) {
+ continue
+ }
+
+ const parentNodePrivacyLevel = getNodePrivacyLevel(
+ node.parentNode!,
+ transaction.scope.configuration.defaultPrivacyLevel,
+ nodePrivacyLevelCache
+ )
+ if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) {
+ continue
+ }
+
+ const serializedNode = serializeNode(node, parentNodePrivacyLevel, transaction)
+ if (!serializedNode) {
+ continue
+ }
+
+ const parentNode = getParentNode(node)!
+ addedNodeMutations.push({
+ nextId: getNextSibling(node),
+ parentId: transaction.scope.nodeIds.get(parentNode)!,
+ node: serializedNode,
+ })
+ }
+ // Finally, we emit remove mutations.
+ const removedNodeMutations: RemovedNodeMutation[] = []
+ removedNodes.forEach((parent, node) => {
+ const parentId = transaction.scope.nodeIds.get(parent)
+ const id = transaction.scope.nodeIds.get(node)
+ if (parentId !== undefined && id !== undefined) {
+ removedNodeMutations.push({ parentId, id })
+ }
+ })
+
+ return { adds: addedNodeMutations, removes: removedNodeMutations, hasBeenSerialized }
+
+ function hasBeenSerialized(node: Node) {
+ const id = transaction.scope.nodeIds.get(node)
+ return id !== undefined && transaction.serializedNodeIds?.has(id)
+ }
+
+ function getNextSibling(node: Node): null | number {
+ let nextSibling = node.nextSibling
+ while (nextSibling) {
+ const id = transaction.scope.nodeIds.get(nextSibling)
+ if (id !== undefined) {
+ return id
+ }
+ nextSibling = nextSibling.nextSibling
+ }
+
+ return null
+ }
+}
+
+function processCharacterDataMutations(
+ mutations: Array>,
+ nodePrivacyLevelCache: NodePrivacyLevelCache,
+ transaction: SerializationTransaction
+) {
+ const textMutations: TextMutation[] = []
+
+ // Deduplicate mutations based on their target node
+ const handledNodes = new Set()
+ const filteredMutations = mutations.filter((mutation) => {
+ if (handledNodes.has(mutation.target)) {
+ return false
+ }
+ handledNodes.add(mutation.target)
+ return true
+ })
+
+ // Emit mutations
+ for (const mutation of filteredMutations) {
+ const value = mutation.target.textContent
+ if (value === mutation.oldValue) {
+ continue
+ }
+
+ const id = transaction.scope.nodeIds.get(mutation.target)
+ if (id === undefined) {
+ continue
+ }
+
+ const parentNodePrivacyLevel = getNodePrivacyLevel(
+ getParentNode(mutation.target)!,
+ transaction.scope.configuration.defaultPrivacyLevel,
+ nodePrivacyLevelCache
+ )
+ if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) {
+ continue
+ }
+
+ textMutations.push({
+ id,
+ value: getTextContent(mutation.target, parentNodePrivacyLevel) ?? null,
+ })
+ }
+
+ return textMutations
+}
+
+function processAttributesMutations(
+ mutations: Array>,
+ nodePrivacyLevelCache: NodePrivacyLevelCache,
+ transaction: SerializationTransaction
+) {
+ const attributeMutations: AttributeMutation[] = []
+
+ // Deduplicate mutations based on their target node and changed attribute
+ const handledElements = new Map>()
+ const filteredMutations = mutations.filter((mutation) => {
+ const handledAttributes = handledElements.get(mutation.target)
+ if (handledAttributes && handledAttributes.has(mutation.attributeName!)) {
+ return false
+ }
+ if (!handledAttributes) {
+ handledElements.set(mutation.target, new Set([mutation.attributeName!]))
+ } else {
+ handledAttributes.add(mutation.attributeName!)
+ }
+ return true
+ })
+
+ // Emit mutations
+ const emittedMutations = new Map()
+ for (const mutation of filteredMutations) {
+ const uncensoredValue = mutation.target.getAttribute(mutation.attributeName!)
+ if (uncensoredValue === mutation.oldValue) {
+ continue
+ }
+
+ const id = transaction.scope.nodeIds.get(mutation.target)
+ if (id === undefined) {
+ continue
+ }
+
+ const privacyLevel = getNodePrivacyLevel(
+ mutation.target,
+ transaction.scope.configuration.defaultPrivacyLevel,
+ nodePrivacyLevelCache
+ )
+ const attributeValue = serializeAttribute(
+ mutation.target,
+ privacyLevel,
+ mutation.attributeName!,
+ transaction.scope.configuration
+ )
+
+ let transformedValue: string | null
+ if (mutation.attributeName === 'value') {
+ const inputValue = getElementInputValue(mutation.target, privacyLevel)
+ if (inputValue === undefined) {
+ continue
+ }
+ transformedValue = inputValue
+ } else if (typeof attributeValue === 'string') {
+ transformedValue = attributeValue
+ } else {
+ transformedValue = null
+ }
+
+ let emittedMutation = emittedMutations.get(mutation.target)
+ if (!emittedMutation) {
+ emittedMutation = { id, attributes: {} }
+ attributeMutations.push(emittedMutation)
+ emittedMutations.set(mutation.target, emittedMutation)
+ }
+
+ emittedMutation.attributes[mutation.attributeName!] = transformedValue
+ }
+
+ return attributeMutations
+}
+
+export function sortAddedAndMovedNodes(nodes: Node[]) {
+ nodes.sort((a, b) => {
+ const position = a.compareDocumentPosition(b)
+ /* eslint-disable no-bitwise */
+ if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
+ return -1
+ } else if (position & Node.DOCUMENT_POSITION_CONTAINS) {
+ return 1
+ } else if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
+ return 1
+ } else if (position & Node.DOCUMENT_POSITION_PRECEDING) {
+ return -1
+ }
+ /* eslint-enable no-bitwise */
+ return 0
+ })
+}
+
+function traverseRemovedShadowDom(removedNode: Node, shadowDomRemovedCallback: RemoveShadowRootCallBack) {
+ if (isNodeShadowHost(removedNode)) {
+ shadowDomRemovedCallback(removedNode.shadowRoot)
+ }
+ forEachChildNodes(removedNode, (childNode) => traverseRemovedShadowDom(childNode, shadowDomRemovedCallback))
+}
+
+export function idsAreAssignedForNodeAndAncestors(node: Node, nodeIds: NodeIds): node is NodeWithSerializedNode {
+ let current: Node | null = node
+ while (current) {
+ if (nodeIds.get(current) === undefined && !isNodeShadowRoot(current)) {
+ return false
+ }
+ current = getParentNode(current)
+ }
+ return true
+}
diff --git a/packages/rum/src/domain/record/startFullSnapshots.spec.ts b/packages/rum/src/domain/record/startFullSnapshots.spec.ts
index bb13677a13..6d7aee046c 100644
--- a/packages/rum/src/domain/record/startFullSnapshots.spec.ts
+++ b/packages/rum/src/domain/record/startFullSnapshots.spec.ts
@@ -1,8 +1,7 @@
import type { ViewCreatedEvent } from '@datadog/browser-rum-core'
import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core'
import type { TimeStamp } from '@datadog/browser-core'
-import { ExperimentalFeature, noop } from '@datadog/browser-core'
-import { mockExperimentalFeatures } from '@datadog/browser-core/test'
+import { addExperimentalFeatures, ExperimentalFeature, noop } from '@datadog/browser-core'
import type { BrowserRecord } from '../../types'
import { RecordType } from '../../types'
import { appendElement } from '../../../../rum-core/test'
@@ -118,7 +117,7 @@ describe('startFullSnapshots', () => {
describe('when generating BrowserChangeRecord', () => {
beforeEach(() => {
- mockExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS])
+ addExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS])
})
describeStartFullSnapshotsWithExpectedSnapshot({
diff --git a/packages/rum/src/domain/record/startFullSnapshots.ts b/packages/rum/src/domain/record/startFullSnapshots.ts
index e10d7de96f..6d50e61243 100644
--- a/packages/rum/src/domain/record/startFullSnapshots.ts
+++ b/packages/rum/src/domain/record/startFullSnapshots.ts
@@ -1,30 +1,31 @@
-import { LifeCycleEventType, getScrollX, getScrollY, getViewportDimension } from '@datadog/browser-rum-core'
+import { LifeCycleEventType, getViewportDimension } from '@datadog/browser-rum-core'
import type { LifeCycle } from '@datadog/browser-rum-core'
import { ExperimentalFeature, isExperimentalFeatureEnabled, timeStampNow } from '@datadog/browser-core'
import type { TimeStamp } from '@datadog/browser-core'
-import type { BrowserFullSnapshotRecord } from '../../types'
import { RecordType } from '../../types'
-import type { ChangeSerializationTransaction, SerializationTransaction } from './serialization'
-import {
- createRootInsertionCursor,
- serializeChangesInTransaction,
- serializeDocument,
- serializeInTransaction,
- serializeNodeAsChange,
- SerializationKind,
-} from './serialization'
+import { SerializationKind, serializeFullSnapshotAsChange, serializeFullSnapshot } from './serialization'
import { getVisualViewport } from './viewports'
import type { RecordingScope } from './recordingScope'
import type { EmitRecordCallback, EmitStatsCallback } from './record.types'
+export type SerializeFullSnapshotCallback = (
+ timestamp: TimeStamp,
+ kind: SerializationKind,
+ document: Document,
+ emitRecord: EmitRecordCallback,
+ emitStats: EmitStatsCallback,
+ scope: RecordingScope
+) => void
+
export function startFullSnapshots(
lifeCycle: LifeCycle,
emitRecord: EmitRecordCallback,
emitStats: EmitStatsCallback,
flushMutations: () => void,
- scope: RecordingScope
+ scope: RecordingScope,
+ serialize: SerializeFullSnapshotCallback = defaultSerializeFullSnapshotCallback()
) {
- takeFullSnapshot(timeStampNow(), SerializationKind.INITIAL_FULL_SNAPSHOT, emitRecord, emitStats, scope)
+ takeFullSnapshot(timeStampNow(), SerializationKind.INITIAL_FULL_SNAPSHOT, emitRecord, emitStats, scope, serialize)
const { unsubscribe } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, (view) => {
flushMutations()
@@ -33,7 +34,8 @@ export function startFullSnapshots(
SerializationKind.SUBSEQUENT_FULL_SNAPSHOT,
emitRecord,
emitStats,
- scope
+ scope,
+ serialize
)
})
@@ -47,7 +49,8 @@ export function takeFullSnapshot(
kind: SerializationKind,
emitRecord: EmitRecordCallback,
emitStats: EmitStatsCallback,
- scope: RecordingScope
+ scope: RecordingScope,
+ serialize: SerializeFullSnapshotCallback = defaultSerializeFullSnapshotCallback()
): void {
const { width, height } = getViewportDimension()
emitRecord({
@@ -68,28 +71,7 @@ export function takeFullSnapshot(
timestamp,
})
- if (isExperimentalFeatureEnabled(ExperimentalFeature.USE_CHANGE_RECORDS)) {
- scope.resetIds()
- serializeChangesInTransaction(
- kind,
- emitRecord,
- emitStats,
- scope,
- timestamp,
- (transaction: ChangeSerializationTransaction) => {
- serializeNodeAsChange(
- createRootInsertionCursor(scope.nodeIds),
- document,
- scope.configuration.defaultPrivacyLevel,
- transaction
- )
- }
- )
- } else {
- serializeInTransaction(kind, emitRecord, emitStats, scope, (transaction: SerializationTransaction) => {
- transaction.add(serializeFullSnapshotRecord(timestamp, transaction))
- })
- }
+ serialize(timestamp, kind, document, emitRecord, emitStats, scope)
if (window.visualViewport) {
emitRecord({
@@ -100,19 +82,8 @@ export function takeFullSnapshot(
}
}
-function serializeFullSnapshotRecord(
- timestamp: TimeStamp,
- transaction: SerializationTransaction
-): BrowserFullSnapshotRecord {
- return {
- data: {
- node: serializeDocument(document, transaction),
- initialOffset: {
- left: getScrollX(),
- top: getScrollY(),
- },
- },
- type: RecordType.FullSnapshot,
- timestamp,
- }
+function defaultSerializeFullSnapshotCallback(): SerializeFullSnapshotCallback {
+ return isExperimentalFeatureEnabled(ExperimentalFeature.USE_CHANGE_RECORDS)
+ ? serializeFullSnapshotAsChange
+ : serializeFullSnapshot
}
diff --git a/packages/rum/src/domain/record/test/serialization.specHelper.ts b/packages/rum/src/domain/record/test/serialization.specHelper.ts
index 34bfb7a6b6..a1bd7f0f64 100644
--- a/packages/rum/src/domain/record/test/serialization.specHelper.ts
+++ b/packages/rum/src/domain/record/test/serialization.specHelper.ts
@@ -1,7 +1,9 @@
+import type { TimeStamp } from '@datadog/browser-core'
import { noop, timeStampNow } from '@datadog/browser-core'
import { RecordType } from '../../../types'
import type {
BrowserChangeRecord,
+ BrowserFullSnapshotRecord,
BrowserRecord,
DocumentNode,
ElementNode,
@@ -18,8 +20,7 @@ import type {
import {
serializeNode,
SerializationKind,
- serializeDocument,
- serializeInTransaction,
+ serializeFullSnapshot,
updateSerializationStats,
serializeChangesInTransaction,
createRootInsertionCursor,
@@ -56,19 +57,15 @@ export function createSerializationTransactionForTesting({
}
export function takeFullSnapshotForTesting(scope: RecordingScope): DocumentNode & SerializedNodeWithId {
- let node: (DocumentNode & SerializedNodeWithId) | null
+ let node: DocumentNode & SerializedNodeWithId
+ const emitRecord = (record: BrowserRecord) => {
+ // Tests want to assert against the serialized node representation of the document,
+ // not the record that would contain it if we emitted it, so we just extract it here.
+ const fullSnapshotRecord = record as BrowserFullSnapshotRecord
+ node = fullSnapshotRecord.data.node as DocumentNode & SerializedNodeWithId
+ }
- serializeInTransaction(
- SerializationKind.INITIAL_FULL_SNAPSHOT,
- noop,
- noop,
- scope,
- (transaction: SerializationTransaction): void => {
- // Tests want to assert against the serialized node representation of the document,
- // not the record that would contain it if we emitted it, so don't bother emitting.
- node = serializeDocument(document, transaction)
- }
- )
+ serializeFullSnapshot(0 as TimeStamp, SerializationKind.INITIAL_FULL_SNAPSHOT, document, emitRecord, noop, scope)
return node!
}
diff --git a/packages/rum/src/domain/record/trackers/trackMutation.spec.ts b/packages/rum/src/domain/record/trackers/trackMutation.spec.ts
index c49d9c8c5b..9d7f4db699 100644
--- a/packages/rum/src/domain/record/trackers/trackMutation.spec.ts
+++ b/packages/rum/src/domain/record/trackers/trackMutation.spec.ts
@@ -12,6 +12,8 @@ import type {
Attributes,
BrowserIncrementalSnapshotRecord,
BrowserMutationPayload,
+ DocumentNode,
+ SerializedNodeWithId,
} from '../../../types'
import { NodeType } from '../../../types'
import type { RecordingScope } from '../recordingScope'
@@ -20,7 +22,8 @@ import type { AddShadowRootCallBack, RemoveShadowRootCallBack } from '../shadowR
import { appendElement, appendText } from '../../../../../rum-core/test'
import type { EmitRecordCallback, EmitStatsCallback } from '../record.types'
import { takeFullSnapshotForTesting } from '../test/serialization.specHelper'
-import { idsAreAssignedForNodeAndAncestors, sortAddedAndMovedNodes, trackMutation } from './trackMutation'
+import { serializeMutations } from '../serialization'
+import { trackMutation } from './trackMutation'
import type { MutationTracker } from './trackMutation'
describe('trackMutation', () => {
@@ -48,28 +51,49 @@ describe('trackMutation', () => {
})
}
- function startMutationCollection(scope: RecordingScope): MutationTracker {
- const mutationTracker = trackMutation(document, emitRecordCallback, emitStatsCallback, scope)
- registerCleanupTask(() => {
- mutationTracker.stop()
- })
- return mutationTracker
- }
-
function getLatestMutationPayload(): BrowserMutationPayload {
const latestRecord = emitRecordCallback.calls.mostRecent()?.args[0] as BrowserIncrementalSnapshotRecord
return latestRecord.data as BrowserMutationPayload
}
- describe('childList mutation records', () => {
- it('emits a mutation when a node is appended to a known node', () => {
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
+ function recordMutation(
+ mutation: () => void,
+ options: {
+ mutationBeforeTrackingStarts?: () => void
+ scope?: RecordingScope
+ skipFlush?: boolean
+ } = {}
+ ): {
+ mutationTracker: MutationTracker
+ serializedDocument: DocumentNode & SerializedNodeWithId
+ } {
+ const scope = options.scope || getRecordingScope()
+
+ const serializedDocument = takeFullSnapshotForTesting(scope)
+
+ if (options.mutationBeforeTrackingStarts) {
+ options.mutationBeforeTrackingStarts()
+ }
+
+ const mutationTracker = trackMutation(document, emitRecordCallback, emitStatsCallback, scope, serializeMutations)
+ registerCleanupTask(() => {
+ mutationTracker.stop()
+ })
+
+ mutation()
- appendElement('', sandbox)
+ if (!options.skipFlush) {
mutationTracker.flush()
+ }
+
+ return { mutationTracker, serializedDocument }
+ }
+ describe('childList mutation records', () => {
+ it('emits a mutation when a node is appended to a known node', () => {
+ const { serializedDocument } = recordMutation(() => {
+ appendElement('', sandbox)
+ })
expect(emitRecordCallback).toHaveBeenCalledTimes(1)
const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
@@ -83,14 +107,78 @@ describe('trackMutation', () => {
})
})
- it('emits serialization stats with mutations', () => {
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
+ it('emits add node mutations in the expected order', () => {
+ const a = appendElement('', sandbox)
+ const aa = appendElement('', a)
+ const b = appendElement('', sandbox)
+ const bb = appendElement('', b)
+ const c = appendElement('', sandbox)
+ const cc = appendElement('', c)
+
+ const { serializedDocument } = recordMutation(() => {
+ const ab = document.createElement('ab')
+ const ac = document.createElement('ac')
+ const ba = document.createElement('ba')
+ const bc = document.createElement('bc')
+ const ca = document.createElement('ca')
+ const cb = document.createElement('cb')
+
+ cc.before(cb)
+ aa.after(ac)
+ bb.before(ba)
+ aa.after(ab)
+ cb.before(ca)
+ bb.after(bc)
+ })
+ expect(emitRecordCallback).toHaveBeenCalledTimes(1)
+
+ const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
+ const cb = expectNewNode({ type: NodeType.Element, tagName: 'cb' })
+ const ca = expectNewNode({ type: NodeType.Element, tagName: 'ca' })
+ const bc = expectNewNode({ type: NodeType.Element, tagName: 'bc' })
+ const ba = expectNewNode({ type: NodeType.Element, tagName: 'ba' })
+ const ac = expectNewNode({ type: NodeType.Element, tagName: 'ac' })
+ const ab = expectNewNode({ type: NodeType.Element, tagName: 'ab' })
+ validate(getLatestMutationPayload(), {
+ adds: [
+ {
+ parent: expectInitialNode({ tag: 'c' }),
+ node: cb,
+ next: expectInitialNode({ tag: 'cc' }),
+ },
+ {
+ parent: expectInitialNode({ tag: 'c' }),
+ node: ca,
+ next: cb,
+ },
+ {
+ parent: expectInitialNode({ tag: 'b' }),
+ node: bc,
+ },
+ {
+ parent: expectInitialNode({ tag: 'b' }),
+ node: ba,
+ next: expectInitialNode({ tag: 'bb' }),
+ },
+ {
+ parent: expectInitialNode({ tag: 'a' }),
+ node: ac,
+ },
+ {
+ parent: expectInitialNode({ tag: 'a' }),
+ node: ab,
+ next: ac,
+ },
+ ],
+ })
+ })
+ it('emits serialization stats with mutations', () => {
const cssText = 'body { width: 100%; }'
- appendElement(``, sandbox)
- mutationTracker.flush()
+
+ const { serializedDocument } = recordMutation(() => {
+ appendElement(``, sandbox)
+ })
expect(emitRecordCallback).toHaveBeenCalledTimes(1)
@@ -115,11 +203,12 @@ describe('trackMutation', () => {
})
it('processes mutations asynchronously', async () => {
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- startMutationCollection(scope)
-
- appendElement('', sandbox)
+ recordMutation(
+ () => {
+ appendElement('', sandbox)
+ },
+ { skipFlush: true }
+ )
expect(emitRecordCallback).not.toHaveBeenCalled()
@@ -127,28 +216,40 @@ describe('trackMutation', () => {
})
it('does not emit a mutation when a node is appended to a unknown node', () => {
- const scope = getRecordingScope()
-
- // Here, we don't call takeFullSnapshotForTesting(), so the sandbox is 'unknown'.
- const mutationTracker = startMutationCollection(scope)
+ const unknownNode = document.createElement('div')
+ registerCleanupTask(() => {
+ unknownNode.remove()
+ })
- appendElement('', sandbox)
- mutationTracker.flush()
+ recordMutation(
+ () => {
+ appendElement('', unknownNode)
+ },
+ {
+ mutationBeforeTrackingStarts() {
+ // Append the node after the full snapshot, but before tracking starts,
+ // rendering it 'unknown'.
+ sandbox.appendChild(unknownNode)
+ },
+ }
+ )
expect(emitRecordCallback).not.toHaveBeenCalled()
})
it('emits buffered mutation records on flush', () => {
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- appendElement('', sandbox)
+ const { mutationTracker } = recordMutation(
+ () => {
+ appendElement('', sandbox)
+ },
+ {
+ skipFlush: true,
+ }
+ )
expect(emitRecordCallback).toHaveBeenCalledTimes(0)
mutationTracker.flush()
-
expect(emitRecordCallback).toHaveBeenCalledTimes(1)
})
@@ -156,13 +257,10 @@ describe('trackMutation', () => {
it('attribute mutations', () => {
const element = appendElement('', sandbox)
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- element.setAttribute('foo', 'bar')
- sandbox.remove()
- mutationTracker.flush()
+ recordMutation(() => {
+ element.setAttribute('foo', 'bar')
+ sandbox.remove()
+ })
expect(getLatestMutationPayload().attributes).toEqual([])
})
@@ -170,25 +268,19 @@ describe('trackMutation', () => {
it('text mutations', () => {
const textNode = appendText('text', sandbox)
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- textNode.data = 'bar'
- sandbox.remove()
- mutationTracker.flush()
+ recordMutation(() => {
+ textNode.data = 'bar'
+ sandbox.remove()
+ })
expect(getLatestMutationPayload().texts).toEqual([])
})
it('add mutations', () => {
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- appendElement('
', sandbox)
- sandbox.remove()
- mutationTracker.flush()
+ recordMutation(() => {
+ appendElement('
', sandbox)
+ sandbox.remove()
+ })
expect(getLatestMutationPayload().adds).toEqual([])
})
@@ -196,13 +288,10 @@ describe('trackMutation', () => {
it('remove mutations', () => {
const element = appendElement('', sandbox)
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- element.remove()
- sandbox.remove()
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ element.remove()
+ sandbox.remove()
+ })
const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -225,15 +314,11 @@ describe('trackMutation', () => {
it('attribute mutations', () => {
const element = appendElement('', sandbox)
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- element.remove()
- sandbox.appendChild(element)
-
- element.setAttribute('foo', 'bar')
- mutationTracker.flush()
+ recordMutation(() => {
+ element.remove()
+ sandbox.appendChild(element)
+ element.setAttribute('foo', 'bar')
+ })
expect(getLatestMutationPayload().attributes).toEqual([])
})
@@ -241,15 +326,11 @@ describe('trackMutation', () => {
it('text mutations', () => {
const textNode = appendText('foo', sandbox)
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- textNode.remove()
- sandbox.appendChild(textNode)
-
- textNode.data = 'bar'
- mutationTracker.flush()
+ recordMutation(() => {
+ textNode.remove()
+ sandbox.appendChild(textNode)
+ textNode.data = 'bar'
+ })
expect(getLatestMutationPayload().texts).toEqual([])
})
@@ -258,17 +339,14 @@ describe('trackMutation', () => {
const child = appendElement('', sandbox)
const parent = child.parentElement!
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- // Generate a mutation on 'child'
- child.remove()
- parent.appendChild(child)
- // Generate a mutation on 'parent'
- parent.remove()
- sandbox.appendChild(parent)
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ // Generate a mutation on 'child'
+ child.remove()
+ parent.appendChild(child)
+ // Generate a mutation on 'parent'
+ parent.remove()
+ sandbox.appendChild(parent)
+ })
const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
@@ -295,14 +373,10 @@ describe('trackMutation', () => {
})
it('remove mutations', () => {
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- const child = appendElement('', sandbox)
-
- child.remove()
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ const child = appendElement('', sandbox)
+ child.remove()
+ })
const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -317,16 +391,11 @@ describe('trackMutation', () => {
})
it('emits only an "add" mutation when adding, removing then re-adding a child', () => {
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- const element = appendElement('', sandbox)
-
- element.remove()
- sandbox.appendChild(element)
-
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ const element = appendElement('', sandbox)
+ element.remove()
+ sandbox.appendChild(element)
+ })
const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -342,14 +411,10 @@ describe('trackMutation', () => {
it('emits an "add" and a "remove" mutation when moving a node', () => {
const a = appendElement('', sandbox)
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- // Moves 'a' after 'b'
- sandbox.appendChild(a)
-
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ // Moves 'a' after 'b'
+ sandbox.appendChild(a)
+ })
const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -378,14 +443,10 @@ describe('trackMutation', () => {
const a = span.nextElementSibling!
const b = a.nextElementSibling!
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- a.appendChild(span)
- b.appendChild(span)
-
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ a.appendChild(span)
+ b.appendChild(span)
+ })
const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -405,13 +466,9 @@ describe('trackMutation', () => {
})
it('keep nodes order when adding multiple sibling nodes', () => {
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- appendElement('', sandbox)
-
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ appendElement('', sandbox)
+ })
const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidator(serializedDocument)
const c = expectNewNode({ type: NodeType.Element, tagName: 'c' })
@@ -438,12 +495,12 @@ describe('trackMutation', () => {
})
it('respects the default privacy level setting', () => {
- const scope = getRecordingScope(DefaultPrivacyLevel.MASK)
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- sandbox.innerText = 'foo bar'
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(
+ () => {
+ sandbox.innerText = 'foo bar'
+ },
+ { scope: getRecordingScope(DefaultPrivacyLevel.MASK) }
+ )
const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -461,14 +518,12 @@ describe('trackMutation', () => {
describe('for shadow DOM', () => {
it('should call addShadowRoot when host is added', () => {
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- const host = appendElement('', sandbox)
- const shadowRoot = host.attachShadow({ mode: 'open' })
- appendElement('', shadowRoot)
- mutationTracker.flush()
+ let shadowRoot: ShadowRoot
+ const { serializedDocument } = recordMutation(() => {
+ const host = appendElement('', sandbox)
+ shadowRoot = host.attachShadow({ mode: 'open' })
+ appendElement('', shadowRoot)
+ })
expect(emitRecordCallback).toHaveBeenCalledTimes(1)
const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
@@ -484,7 +539,7 @@ describe('trackMutation', () => {
},
],
})
- expect(addShadowRootSpy).toHaveBeenCalledOnceWith(shadowRoot, jasmine.anything())
+ expect(addShadowRootSpy).toHaveBeenCalledOnceWith(shadowRoot!, jasmine.anything())
expect(removeShadowRootSpy).not.toHaveBeenCalled()
})
@@ -493,12 +548,10 @@ describe('trackMutation', () => {
const shadowRoot = host.attachShadow({ mode: 'open' })
appendElement('', shadowRoot)
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
+ const { serializedDocument } = recordMutation(() => {
+ host.remove()
+ })
- host.remove()
- mutationTracker.flush()
expect(emitRecordCallback).toHaveBeenCalledTimes(1)
expect(addShadowRootSpy).toHaveBeenCalledTimes(1)
@@ -520,12 +573,10 @@ describe('trackMutation', () => {
const shadowRoot = host.attachShadow({ mode: 'open' })
appendElement('', shadowRoot)
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
+ const { serializedDocument } = recordMutation(() => {
+ host.parentElement!.remove()
+ })
- host.parentElement!.remove()
- mutationTracker.flush()
expect(emitRecordCallback).toHaveBeenCalledTimes(1)
expect(addShadowRootSpy).toHaveBeenCalledTimes(1)
@@ -548,12 +599,10 @@ describe('trackMutation', () => {
const childHost = appendElement('', parentHost.querySelector('p')!)
const childShadowRoot = childHost.attachShadow({ mode: 'open' })
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
+ const { serializedDocument } = recordMutation(() => {
+ parentHost.remove()
+ })
- parentHost.remove()
- mutationTracker.flush()
expect(emitRecordCallback).toHaveBeenCalledTimes(1)
expect(addShadowRootSpy).toHaveBeenCalledTimes(2)
@@ -585,12 +634,9 @@ describe('trackMutation', () => {
})
it('emits a mutation when a text node is changed', () => {
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- textNode.data = 'bar'
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ textNode.data = 'bar'
+ })
expect(emitRecordCallback).toHaveBeenCalledTimes(1)
@@ -608,37 +654,31 @@ describe('trackMutation', () => {
it('emits a mutation when an empty text node is changed', () => {
textNode.data = ''
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- textNode.data = 'bar'
- mutationTracker.flush()
+ recordMutation(() => {
+ textNode.data = 'bar'
+ })
expect(emitRecordCallback).toHaveBeenCalledTimes(1)
})
it('does not emit a mutation when a text node keeps the same value', () => {
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- textNode.data = 'bar'
- textNode.data = 'foo'
- mutationTracker.flush()
+ recordMutation(() => {
+ textNode.data = 'bar'
+ textNode.data = 'foo'
+ })
expect(emitRecordCallback).not.toHaveBeenCalled()
})
it('respects the default privacy level setting', () => {
const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
-
- scope.configuration.defaultPrivacyLevel = DefaultPrivacyLevel.MASK
- const mutationTracker = startMutationCollection(scope)
-
- textNode.data = 'foo bar'
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(
+ () => {
+ scope.configuration.defaultPrivacyLevel = DefaultPrivacyLevel.MASK
+ textNode.data = 'foo bar'
+ },
+ { scope }
+ )
const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -655,12 +695,12 @@ describe('trackMutation', () => {
sandbox.setAttribute('data-dd-privacy', 'allow')
const div = appendElement('foo 81
', sandbox)
- const scope = getRecordingScope(DefaultPrivacyLevel.MASK)
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- div.firstChild!.textContent = 'bazz 7'
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(
+ () => {
+ div.firstChild!.textContent = 'bazz 7'
+ },
+ { scope: getRecordingScope(DefaultPrivacyLevel.MASK) }
+ )
expect(emitRecordCallback).toHaveBeenCalledTimes(1)
@@ -678,12 +718,9 @@ describe('trackMutation', () => {
describe('attributes mutations', () => {
it('emits a mutation when an attribute is changed', () => {
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- sandbox.setAttribute('foo', 'bar')
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ sandbox.setAttribute('foo', 'bar')
+ })
expect(emitRecordCallback).toHaveBeenCalledTimes(1)
@@ -699,12 +736,9 @@ describe('trackMutation', () => {
})
it('emits a mutation with an empty string when an attribute is changed to an empty string', () => {
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- sandbox.setAttribute('foo', '')
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ sandbox.setAttribute('foo', '')
+ })
const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -720,12 +754,9 @@ describe('trackMutation', () => {
it('emits a mutation with `null` when an attribute is removed', () => {
sandbox.setAttribute('foo', 'bar')
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- sandbox.removeAttribute('foo')
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ sandbox.removeAttribute('foo')
+ })
const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -741,25 +772,19 @@ describe('trackMutation', () => {
it('does not emit a mutation when an attribute keeps the same value', () => {
sandbox.setAttribute('foo', 'bar')
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- sandbox.setAttribute('foo', 'biz')
- sandbox.setAttribute('foo', 'bar')
- mutationTracker.flush()
+ recordMutation(() => {
+ sandbox.setAttribute('foo', 'biz')
+ sandbox.setAttribute('foo', 'bar')
+ })
expect(emitRecordCallback).not.toHaveBeenCalled()
})
it('reuse the same mutation when multiple attributes are changed', () => {
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- sandbox.setAttribute('foo1', 'biz')
- sandbox.setAttribute('foo2', 'bar')
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ sandbox.setAttribute('foo1', 'biz')
+ sandbox.setAttribute('foo2', 'bar')
+ })
const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -773,12 +798,12 @@ describe('trackMutation', () => {
})
it('respects the default privacy level setting', () => {
- const scope = getRecordingScope(DefaultPrivacyLevel.MASK)
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- sandbox.setAttribute('data-foo', 'biz')
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(
+ () => {
+ sandbox.setAttribute('data-foo', 'biz')
+ },
+ { scope: getRecordingScope(DefaultPrivacyLevel.MASK) }
+ )
const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -800,13 +825,9 @@ describe('trackMutation', () => {
})
it('skips ignored nodes when looking for the next id', () => {
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- sandbox.insertBefore(document.createElement('a'), ignoredElement)
-
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ sandbox.insertBefore(document.createElement('a'), ignoredElement)
+ })
const { validate, expectInitialNode, expectNewNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -823,54 +844,36 @@ describe('trackMutation', () => {
it('when adding an ignored node', () => {
ignoredElement.remove()
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- sandbox.appendChild(ignoredElement)
-
- mutationTracker.flush()
+ recordMutation(() => {
+ sandbox.appendChild(ignoredElement)
+ })
expect(emitRecordCallback).not.toHaveBeenCalled()
})
it('when changing the attributes of an ignored node', () => {
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- ignoredElement.setAttribute('foo', 'bar')
-
- mutationTracker.flush()
+ recordMutation(() => {
+ ignoredElement.setAttribute('foo', 'bar')
+ })
expect(emitRecordCallback).not.toHaveBeenCalled()
})
it('when adding a new child node', () => {
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- appendElement("'function foo() {}'", ignoredElement)
-
- mutationTracker.flush()
+ recordMutation(() => {
+ appendElement("'function foo() {}'", ignoredElement)
+ })
expect(emitRecordCallback).not.toHaveBeenCalled()
})
it('when mutating a known child node', () => {
const textNode = appendText('function foo() {}', sandbox)
-
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
-
ignoredElement.appendChild(textNode)
- const mutationTracker = startMutationCollection(scope)
-
- textNode.data = 'function bar() {}'
-
- mutationTracker.flush()
+ recordMutation(() => {
+ textNode.data = 'function bar() {}'
+ })
expect(emitRecordCallback).not.toHaveBeenCalled()
})
@@ -878,13 +881,9 @@ describe('trackMutation', () => {
it('when adding a known child node', () => {
const textNode = appendText('function foo() {}', sandbox)
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- ignoredElement.appendChild(textNode)
-
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ ignoredElement.appendChild(textNode)
+ })
const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -900,12 +899,9 @@ describe('trackMutation', () => {
it('when moving an ignored node', () => {
const script = appendElement('', sandbox)
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- sandbox.appendChild(script)
- mutationTracker.flush()
+ recordMutation(() => {
+ sandbox.appendChild(script)
+ })
expect(emitRecordCallback).not.toHaveBeenCalled()
})
@@ -919,43 +915,29 @@ describe('trackMutation', () => {
})
it('does not emit attribute mutations on hidden nodes', () => {
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- hiddenElement.setAttribute('foo', 'bar')
-
- mutationTracker.flush()
+ recordMutation(() => {
+ hiddenElement.setAttribute('foo', 'bar')
+ })
expect(emitRecordCallback).not.toHaveBeenCalled()
})
describe('does not emit mutations occurring in hidden node', () => {
it('when adding a new node', () => {
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- appendElement('function foo() {}', hiddenElement)
-
- mutationTracker.flush()
+ recordMutation(() => {
+ appendElement('function foo() {}', hiddenElement)
+ })
expect(emitRecordCallback).not.toHaveBeenCalled()
})
it('when mutating a known child node', () => {
const textNode = appendText('function foo() {}', sandbox)
-
- const scope = getRecordingScope()
- takeFullSnapshotForTesting(scope)
-
hiddenElement.appendChild(textNode)
- const mutationTracker = startMutationCollection(scope)
-
- textNode.data = 'function bar() {}'
-
- mutationTracker.flush()
+ recordMutation(() => {
+ textNode.data = 'function bar() {}'
+ })
expect(emitRecordCallback).not.toHaveBeenCalled()
})
@@ -963,13 +945,9 @@ describe('trackMutation', () => {
it('when moving a known node into an hidden node', () => {
const textNode = appendText('function foo() {}', sandbox)
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- hiddenElement.appendChild(textNode)
-
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ hiddenElement.appendChild(textNode)
+ })
const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -1054,12 +1032,9 @@ describe('trackMutation', () => {
sandbox.setAttribute(PRIVACY_ATTR_NAME, privacyAttributeValue)
}
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- sandbox.appendChild(input)
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ sandbox.appendChild(input)
+ })
const { validate, expectNewNode, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
validate(getLatestMutationPayload(), {
@@ -1086,12 +1061,9 @@ describe('trackMutation', () => {
}
sandbox.appendChild(input)
- const scope = getRecordingScope()
- const serializedDocument = takeFullSnapshotForTesting(scope)
- const mutationTracker = startMutationCollection(scope)
-
- input.setAttribute('value', 'bar')
- mutationTracker.flush()
+ const { serializedDocument } = recordMutation(() => {
+ input.setAttribute('value', 'bar')
+ })
if (expectedAttributesMutation) {
const { validate, expectInitialNode } = createMutationPayloadValidator(serializedDocument)
@@ -1106,113 +1078,3 @@ describe('trackMutation', () => {
}
})
})
-
-describe('sortAddedAndMovedNodes', () => {
- let parent: Node
- let a: Node
- let aa: Node
- let b: Node
- let c: Node
- let d: Node
-
- beforeEach(() => {
- // Create a tree like this:
- // parent
- // / | \ \
- // a b c d
- // |
- // aa
- a = document.createElement('a')
- aa = document.createElement('aa')
- b = document.createElement('b')
- c = document.createElement('c')
- d = document.createElement('d')
- parent = document.createElement('parent')
- parent.appendChild(a)
- a.appendChild(aa)
- parent.appendChild(b)
- parent.appendChild(c)
- parent.appendChild(d)
- })
-
- it('sorts siblings in reverse order', () => {
- const nodes = [c, b, d, a]
- sortAddedAndMovedNodes(nodes)
- expect(nodes).toEqual([d, c, b, a])
- })
-
- it('sorts parents', () => {
- const nodes = [a, parent, aa]
- sortAddedAndMovedNodes(nodes)
- expect(nodes).toEqual([parent, a, aa])
- })
-
- it('sorts parents first then siblings', () => {
- const nodes = [c, aa, b, parent, d, a]
- sortAddedAndMovedNodes(nodes)
- expect(nodes).toEqual([parent, d, c, b, a, aa])
- })
-})
-
-describe('idsAreAssignedForNodeAndAncestors', () => {
- let scope: RecordingScope
-
- beforeEach(() => {
- scope = createRecordingScopeForTesting()
- })
-
- it('returns false for DOM Nodes that have not been assigned an id', () => {
- expect(idsAreAssignedForNodeAndAncestors(document.createElement('div'), scope.nodeIds)).toBe(false)
- })
-
- it('returns true for DOM Nodes that have been assigned an id', () => {
- const node = document.createElement('div')
- scope.nodeIds.getOrInsert(node)
- expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true)
- })
-
- it('returns false for DOM Nodes when an ancestor has not been assigned an id', () => {
- const node = document.createElement('div')
- scope.nodeIds.getOrInsert(node)
-
- const parent = document.createElement('div')
- parent.appendChild(node)
- scope.nodeIds.getOrInsert(parent)
-
- const grandparent = document.createElement('div')
- grandparent.appendChild(parent)
-
- expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(false)
- })
-
- it('returns true for DOM Nodes when all ancestors have been assigned an id', () => {
- const node = document.createElement('div')
- scope.nodeIds.getOrInsert(node)
-
- const parent = document.createElement('div')
- parent.appendChild(node)
- scope.nodeIds.getOrInsert(parent)
-
- const grandparent = document.createElement('div')
- grandparent.appendChild(parent)
- scope.nodeIds.getOrInsert(grandparent)
-
- expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true)
- })
-
- it('returns true for DOM Nodes in shadow subtrees', () => {
- const node = document.createElement('div')
- scope.nodeIds.getOrInsert(node)
-
- const parent = document.createElement('div')
- parent.appendChild(node)
- scope.nodeIds.getOrInsert(parent)
-
- const grandparent = document.createElement('div')
- const shadowRoot = grandparent.attachShadow({ mode: 'open' })
- shadowRoot.appendChild(parent)
- scope.nodeIds.getOrInsert(grandparent)
-
- expect(idsAreAssignedForNodeAndAncestors(node, scope.nodeIds)).toBe(true)
- })
-})
diff --git a/packages/rum/src/domain/record/trackers/trackMutation.ts b/packages/rum/src/domain/record/trackers/trackMutation.ts
index e8194d6d28..29f9e48432 100644
--- a/packages/rum/src/domain/record/trackers/trackMutation.ts
+++ b/packages/rum/src/domain/record/trackers/trackMutation.ts
@@ -1,50 +1,23 @@
-import { monitor, noop } from '@datadog/browser-core'
-import type {
- NodePrivacyLevelCache,
- RumMutationRecord,
- RumChildListMutationRecord,
- RumCharacterDataMutationRecord,
- RumAttributesMutationRecord,
-} from '@datadog/browser-rum-core'
-import {
- isNodeShadowHost,
- getMutationObserverConstructor,
- getParentNode,
- forEachChildNodes,
- getNodePrivacyLevel,
- getTextContent,
- NodePrivacyLevel,
- isNodeShadowRoot,
-} from '@datadog/browser-rum-core'
-import { IncrementalSource } from '../../../types'
-import type {
- BrowserMutationData,
- AddedNodeMutation,
- AttributeMutation,
- RemovedNodeMutation,
- TextMutation,
-} from '../../../types'
+import type { TimeStamp } from '@datadog/browser-core'
+import { monitor, noop, timeStampNow } from '@datadog/browser-core'
+import type { RumMutationRecord } from '@datadog/browser-rum-core'
+import { getMutationObserverConstructor } from '@datadog/browser-rum-core'
import type { RecordingScope } from '../recordingScope'
-import type { SerializationTransaction } from '../serialization'
-import {
- getElementInputValue,
- serializeAttribute,
- serializeInTransaction,
- serializeNode,
- SerializationKind,
-} from '../serialization'
import { createMutationBatch } from '../mutationBatch'
-import type { RemoveShadowRootCallBack } from '../shadowRootsController'
-import { assembleIncrementalSnapshot } from '../assembly'
import type { EmitRecordCallback, EmitStatsCallback } from '../record.types'
-import type { NodeId, NodeIds } from '../itemIds'
+import { serializeMutations } from '../serialization'
import type { Tracker } from './tracker.types'
-export type NodeWithSerializedNode = Node & { __brand: 'NodeWithSerializedNode' }
-type WithSerializedTarget = T & { target: NodeWithSerializedNode }
-
export type MutationTracker = Tracker & { flush: () => void }
+export type SerializeMutationsCallback = (
+ timestamp: TimeStamp,
+ mutations: RumMutationRecord[],
+ emitRecord: EmitRecordCallback,
+ emitStats: EmitStatsCallback,
+ scope: RecordingScope
+) => void
+
/**
* Buffers and aggregate mutations generated by a MutationObserver into MutationPayload
*/
@@ -52,7 +25,8 @@ export function trackMutation(
target: Node,
emitRecord: EmitRecordCallback,
emitStats: EmitStatsCallback,
- scope: RecordingScope
+ scope: RecordingScope,
+ serialize: SerializeMutationsCallback = defaultSerializeMutationsCallback()
): MutationTracker {
const MutationObserver = getMutationObserverConstructor()
if (!MutationObserver) {
@@ -60,13 +34,12 @@ export function trackMutation(
}
const mutationBatch = createMutationBatch((mutations) => {
- serializeInTransaction(
- SerializationKind.INCREMENTAL_SNAPSHOT,
+ serialize(
+ timeStampNow(),
+ mutations.concat(observer.takeRecords() as RumMutationRecord[]),
emitRecord,
emitStats,
- scope,
- (transaction: SerializationTransaction) =>
- processMutations(mutations.concat(observer.takeRecords() as RumMutationRecord[]), transaction)
+ scope
)
})
@@ -92,329 +65,6 @@ export function trackMutation(
}
}
-function processMutations(mutations: RumMutationRecord[], transaction: SerializationTransaction): void {
- const nodePrivacyLevelCache: NodePrivacyLevelCache = new Map()
-
- mutations
- .filter((mutation): mutation is RumChildListMutationRecord => mutation.type === 'childList')
- .forEach((mutation) => {
- mutation.removedNodes.forEach((removedNode) => {
- traverseRemovedShadowDom(removedNode, transaction.scope.shadowRootsController.removeShadowRoot)
- })
- })
-
- // Discard any mutation with a 'target' node that:
- // * isn't injected in the current document or isn't known/serialized yet: those nodes are likely
- // part of a mutation occurring in a parent Node
- // * should be hidden or ignored
- const filteredMutations = mutations.filter(
- (mutation): mutation is WithSerializedTarget =>
- mutation.target.isConnected &&
- idsAreAssignedForNodeAndAncestors(mutation.target, transaction.scope.nodeIds) &&
- getNodePrivacyLevel(
- mutation.target,
- transaction.scope.configuration.defaultPrivacyLevel,
- nodePrivacyLevelCache
- ) !== NodePrivacyLevel.HIDDEN
- )
-
- const { adds, removes, hasBeenSerialized } = processChildListMutations(
- filteredMutations.filter(
- (mutation): mutation is WithSerializedTarget => mutation.type === 'childList'
- ),
- nodePrivacyLevelCache,
- transaction
- )
-
- const texts = processCharacterDataMutations(
- filteredMutations.filter(
- (mutation): mutation is WithSerializedTarget =>
- mutation.type === 'characterData' && !hasBeenSerialized(mutation.target)
- ),
- nodePrivacyLevelCache,
- transaction
- )
-
- const attributes = processAttributesMutations(
- filteredMutations.filter(
- (mutation): mutation is WithSerializedTarget =>
- mutation.type === 'attributes' && !hasBeenSerialized(mutation.target)
- ),
- nodePrivacyLevelCache,
- transaction
- )
-
- if (!texts.length && !attributes.length && !removes.length && !adds.length) {
- return
- }
-
- transaction.add(
- assembleIncrementalSnapshot(IncrementalSource.Mutation, {
- adds,
- removes,
- texts,
- attributes,
- })
- )
-}
-
-function processChildListMutations(
- mutations: Array>,
- nodePrivacyLevelCache: NodePrivacyLevelCache,
- transaction: SerializationTransaction
-) {
- // First, we iterate over mutations to collect:
- //
- // * nodes that have been added in the document and not removed by a subsequent mutation
- // * nodes that have been removed from the document but were not added in a previous mutation
- //
- // For this second category, we also collect their previous parent (mutation.target) because we'll
- // need it to emit a 'remove' mutation.
- //
- // Those two categories may overlap: if a node moved from a position to another, it is reported as
- // two mutation records, one with a "removedNodes" and the other with "addedNodes". In this case,
- // the node will be in both sets.
- const addedAndMovedNodes = new Set()
- const removedNodes = new Map()
- for (const mutation of mutations) {
- mutation.addedNodes.forEach((node) => {
- addedAndMovedNodes.add(node)
- })
- mutation.removedNodes.forEach((node) => {
- if (!addedAndMovedNodes.has(node)) {
- removedNodes.set(node, mutation.target)
- }
- addedAndMovedNodes.delete(node)
- })
- }
-
- // Then, we sort nodes that are still in the document by topological order, for two reasons:
- //
- // * We will serialize each added nodes with their descendants. We don't want to serialize a node
- // twice, so we need to iterate over the parent nodes first and skip any node that is contained in
- // a precedent node.
- //
- // * To emit "add" mutations, we need references to the parent and potential next sibling of each
- // added node. So we need to iterate over the parent nodes first, and when multiple nodes are
- // siblings, we want to iterate from last to first. This will ensure that any "next" node is
- // already serialized and have an id.
- const sortedAddedAndMovedNodes = Array.from(addedAndMovedNodes)
- sortAddedAndMovedNodes(sortedAddedAndMovedNodes)
-
- // Then, we iterate over our sorted node sets to emit mutations. We collect the newly serialized
- // node ids in a set to be able to skip subsequent related mutations.
- transaction.serializedNodeIds = new Set()
-
- const addedNodeMutations: AddedNodeMutation[] = []
- for (const node of sortedAddedAndMovedNodes) {
- if (hasBeenSerialized(node)) {
- continue
- }
-
- const parentNodePrivacyLevel = getNodePrivacyLevel(
- node.parentNode!,
- transaction.scope.configuration.defaultPrivacyLevel,
- nodePrivacyLevelCache
- )
- if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) {
- continue
- }
-
- const serializedNode = serializeNode(node, parentNodePrivacyLevel, transaction)
- if (!serializedNode) {
- continue
- }
-
- const parentNode = getParentNode(node)!
- addedNodeMutations.push({
- nextId: getNextSibling(node),
- parentId: transaction.scope.nodeIds.get(parentNode)!,
- node: serializedNode,
- })
- }
- // Finally, we emit remove mutations.
- const removedNodeMutations: RemovedNodeMutation[] = []
- removedNodes.forEach((parent, node) => {
- const parentId = transaction.scope.nodeIds.get(parent)
- const id = transaction.scope.nodeIds.get(node)
- if (parentId !== undefined && id !== undefined) {
- removedNodeMutations.push({ parentId, id })
- }
- })
-
- return { adds: addedNodeMutations, removes: removedNodeMutations, hasBeenSerialized }
-
- function hasBeenSerialized(node: Node) {
- const id = transaction.scope.nodeIds.get(node)
- return id !== undefined && transaction.serializedNodeIds?.has(id)
- }
-
- function getNextSibling(node: Node): null | number {
- let nextSibling = node.nextSibling
- while (nextSibling) {
- const id = transaction.scope.nodeIds.get(nextSibling)
- if (id !== undefined) {
- return id
- }
- nextSibling = nextSibling.nextSibling
- }
-
- return null
- }
-}
-
-function processCharacterDataMutations(
- mutations: Array>,
- nodePrivacyLevelCache: NodePrivacyLevelCache,
- transaction: SerializationTransaction
-) {
- const textMutations: TextMutation[] = []
-
- // Deduplicate mutations based on their target node
- const handledNodes = new Set()
- const filteredMutations = mutations.filter((mutation) => {
- if (handledNodes.has(mutation.target)) {
- return false
- }
- handledNodes.add(mutation.target)
- return true
- })
-
- // Emit mutations
- for (const mutation of filteredMutations) {
- const value = mutation.target.textContent
- if (value === mutation.oldValue) {
- continue
- }
-
- const id = transaction.scope.nodeIds.get(mutation.target)
- if (id === undefined) {
- continue
- }
-
- const parentNodePrivacyLevel = getNodePrivacyLevel(
- getParentNode(mutation.target)!,
- transaction.scope.configuration.defaultPrivacyLevel,
- nodePrivacyLevelCache
- )
- if (parentNodePrivacyLevel === NodePrivacyLevel.HIDDEN || parentNodePrivacyLevel === NodePrivacyLevel.IGNORE) {
- continue
- }
-
- textMutations.push({
- id,
- value: getTextContent(mutation.target, parentNodePrivacyLevel) ?? null,
- })
- }
-
- return textMutations
-}
-
-function processAttributesMutations(
- mutations: Array>,
- nodePrivacyLevelCache: NodePrivacyLevelCache,
- transaction: SerializationTransaction
-) {
- const attributeMutations: AttributeMutation[] = []
-
- // Deduplicate mutations based on their target node and changed attribute
- const handledElements = new Map>()
- const filteredMutations = mutations.filter((mutation) => {
- const handledAttributes = handledElements.get(mutation.target)
- if (handledAttributes && handledAttributes.has(mutation.attributeName!)) {
- return false
- }
- if (!handledAttributes) {
- handledElements.set(mutation.target, new Set([mutation.attributeName!]))
- } else {
- handledAttributes.add(mutation.attributeName!)
- }
- return true
- })
-
- // Emit mutations
- const emittedMutations = new Map()
- for (const mutation of filteredMutations) {
- const uncensoredValue = mutation.target.getAttribute(mutation.attributeName!)
- if (uncensoredValue === mutation.oldValue) {
- continue
- }
-
- const id = transaction.scope.nodeIds.get(mutation.target)
- if (id === undefined) {
- continue
- }
-
- const privacyLevel = getNodePrivacyLevel(
- mutation.target,
- transaction.scope.configuration.defaultPrivacyLevel,
- nodePrivacyLevelCache
- )
- const attributeValue = serializeAttribute(
- mutation.target,
- privacyLevel,
- mutation.attributeName!,
- transaction.scope.configuration
- )
-
- let transformedValue: string | null
- if (mutation.attributeName === 'value') {
- const inputValue = getElementInputValue(mutation.target, privacyLevel)
- if (inputValue === undefined) {
- continue
- }
- transformedValue = inputValue
- } else if (typeof attributeValue === 'string') {
- transformedValue = attributeValue
- } else {
- transformedValue = null
- }
-
- let emittedMutation = emittedMutations.get(mutation.target)
- if (!emittedMutation) {
- emittedMutation = { id, attributes: {} }
- attributeMutations.push(emittedMutation)
- emittedMutations.set(mutation.target, emittedMutation)
- }
-
- emittedMutation.attributes[mutation.attributeName!] = transformedValue
- }
-
- return attributeMutations
-}
-
-export function sortAddedAndMovedNodes(nodes: Node[]) {
- nodes.sort((a, b) => {
- const position = a.compareDocumentPosition(b)
- /* eslint-disable no-bitwise */
- if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
- return -1
- } else if (position & Node.DOCUMENT_POSITION_CONTAINS) {
- return 1
- } else if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
- return 1
- } else if (position & Node.DOCUMENT_POSITION_PRECEDING) {
- return -1
- }
- /* eslint-enable no-bitwise */
- return 0
- })
-}
-
-function traverseRemovedShadowDom(removedNode: Node, shadowDomRemovedCallback: RemoveShadowRootCallBack) {
- if (isNodeShadowHost(removedNode)) {
- shadowDomRemovedCallback(removedNode.shadowRoot)
- }
- forEachChildNodes(removedNode, (childNode) => traverseRemovedShadowDom(childNode, shadowDomRemovedCallback))
-}
-
-export function idsAreAssignedForNodeAndAncestors(node: Node, nodeIds: NodeIds): node is NodeWithSerializedNode {
- let current: Node | null = node
- while (current) {
- if (nodeIds.get(current) === undefined && !isNodeShadowRoot(current)) {
- return false
- }
- current = getParentNode(current)
- }
- return true
+function defaultSerializeMutationsCallback(): SerializeMutationsCallback {
+ return serializeMutations
}
diff --git a/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.spec.ts b/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.spec.ts
index aaa54a6c8e..088abb8e1b 100644
--- a/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.spec.ts
+++ b/packages/rum/src/domain/segmentCollection/startSegmentTelemetry.spec.ts
@@ -1,10 +1,5 @@
import type { Telemetry, HttpRequestEvent, BandwidthStats } from '@datadog/browser-core'
-import {
- addExperimentalFeatures,
- ExperimentalFeature,
- Observable,
- resetExperimentalFeatures,
-} from '@datadog/browser-core'
+import { addExperimentalFeatures, ExperimentalFeature, Observable } from '@datadog/browser-core'
import type { MockTelemetry } from '@datadog/browser-core/test'
import { registerCleanupTask } from '@datadog/browser-core/test'
import { startMockTelemetry } from '../../../../core/test'
@@ -54,15 +49,16 @@ describe('segmentTelemetry', () => {
registerCleanupTask(stopSegmentTelemetry)
}
- it('should collect segment telemetry for all full snapshots', async () => {
- setupSegmentTelemetryCollection()
+ for (const enableChangeRecords of [true, false] as const) {
+ const titlePrefix = enableChangeRecords ? 'with change records' : 'without change records'
- for (const result of ['failure', 'queue-full', 'success'] as const) {
- for (const enableChangeRecords of [true, false] as const) {
- if (enableChangeRecords) {
- addExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS])
- }
+ it(`${titlePrefix}, should collect segment telemetry for all full snapshots`, async () => {
+ if (enableChangeRecords) {
+ addExperimentalFeatures([ExperimentalFeature.USE_CHANGE_RECORDS])
+ }
+ setupSegmentTelemetryCollection()
+ for (const result of ['failure', 'queue-full', 'success'] as const) {
generateReplayRequest({ result, isFullSnapshot: true })
expect(await telemetry.getEvents()).toEqual([
@@ -101,10 +97,9 @@ describe('segmentTelemetry', () => {
])
telemetry.reset()
- resetExperimentalFeatures()
}
- }
- })
+ })
+ }
it('should collect segment telemetry for failed incremental mutation requests', async () => {
setupSegmentTelemetryCollection()