diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts index 4574e69787..65c5ea68a3 100644 --- a/packages/core/src/tools/experimentalFeatures.ts +++ b/packages/core/src/tools/experimentalFeatures.ts @@ -25,6 +25,8 @@ export enum ExperimentalFeature { TOO_MANY_REQUESTS_INVESTIGATION = 'too_many_requests_investigation', COMPOSED_PATH_SELECTOR = 'composed_path_selector', TRACK_RESOURCE_HEADERS = 'track_resource_headers', + PARTIAL_VIEW_UPDATES = 'partial_view_updates', + PARTIAL_VIEW_UPDATES_NO_CHECKPOINT = 'partial_view_updates_no_checkpoint', } const enabledExperimentalFeatures: Set = new Set() diff --git a/packages/rum-core/src/domain/assembly.spec.ts b/packages/rum-core/src/domain/assembly.spec.ts index f362377cff..db1c0ee809 100644 --- a/packages/rum-core/src/domain/assembly.spec.ts +++ b/packages/rum-core/src/domain/assembly.spec.ts @@ -388,27 +388,31 @@ describe('rum assembly', () => { describe('service and version', () => { const extraConfigurationOptions = { service: 'default-service', version: 'default-version' } - Object.values(RumEventType).forEach((eventType) => { - it(`should be modifiable for ${eventType}`, () => { - const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ - partialConfiguration: { - ...extraConfigurationOptions, - beforeSend: (event) => { - event.service = 'bar' - event.version = '0.2.0' + // view_update events bypass the assembly pipeline (created post-assembly in startRumBatch) + // and are intentionally not modifiable via beforeSend. + Object.values(RumEventType) + .filter((eventType) => eventType !== RumEventType.VIEW_UPDATE) + .forEach((eventType) => { + it(`should be modifiable for ${eventType}`, () => { + const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ + partialConfiguration: { + ...extraConfigurationOptions, + beforeSend: (event) => { + event.service = 'bar' + event.version = '0.2.0' - return true + return true + }, }, - }, - }) + }) - notifyRawRumEvent(lifeCycle, { - rawRumEvent: createRawRumEvent(eventType), + notifyRawRumEvent(lifeCycle, { + rawRumEvent: createRawRumEvent(eventType), + }) + expect((serverRumEvents[0] as RumResourceEvent).service).toBe('bar') + expect((serverRumEvents[0] as RumResourceEvent).version).toBe('0.2.0') }) - expect((serverRumEvents[0] as RumResourceEvent).service).toBe('bar') - expect((serverRumEvents[0] as RumResourceEvent).version).toBe('0.2.0') }) - }) it('should be added to the event as ddtags', () => { const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ diff --git a/packages/rum-core/src/domain/assembly.ts b/packages/rum-core/src/domain/assembly.ts index 6a0aafef8a..5ea0a84ac9 100644 --- a/packages/rum-core/src/domain/assembly.ts +++ b/packages/rum-core/src/domain/assembly.ts @@ -49,6 +49,10 @@ export function startRumAssembly( ...VIEW_MODIFIABLE_FIELD_PATHS, ...ROOT_MODIFIABLE_FIELD_PATHS, }, + // view_update events are created post-assembly in startRumBatch.ts and never go through + // this pipeline — they intentionally bypass beforeSend. This entry is required by the + // exhaustive type but is never reached in practice. + [RumEventType.VIEW_UPDATE]: {}, [RumEventType.ERROR]: { 'error.message': 'string', 'error.stack': 'string', diff --git a/packages/rum-core/src/domain/trackEventCounts.ts b/packages/rum-core/src/domain/trackEventCounts.ts index c7a0cc856b..8703643126 100644 --- a/packages/rum-core/src/domain/trackEventCounts.ts +++ b/packages/rum-core/src/domain/trackEventCounts.ts @@ -30,7 +30,7 @@ export function trackEventCounts({ } const subscription = lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event): void => { - if (event.type === 'view' || event.type === 'vital' || !isChildEvent(event)) { + if (event.type === 'view' || event.type === 'view_update' || event.type === 'vital' || !isChildEvent(event)) { return } switch (event.type) { diff --git a/packages/rum-core/src/domain/view/viewDiff.spec.ts b/packages/rum-core/src/domain/view/viewDiff.spec.ts new file mode 100644 index 0000000000..f4d1a2b24a --- /dev/null +++ b/packages/rum-core/src/domain/view/viewDiff.spec.ts @@ -0,0 +1,116 @@ +import { isEqual, diffMerge } from './viewDiff' + +describe('isEqual', () => { + it('should return true for identical primitives', () => { + expect(isEqual(1, 1)).toBe(true) + expect(isEqual('a', 'a')).toBe(true) + expect(isEqual(true, true)).toBe(true) + expect(isEqual(null, null)).toBe(true) + expect(isEqual(undefined, undefined)).toBe(true) + }) + + it('should return false for different primitives', () => { + expect(isEqual(1, 2)).toBe(false) + expect(isEqual('a', 'b')).toBe(false) + expect(isEqual(true, false)).toBe(false) + expect(isEqual(null, undefined)).toBe(false) + }) + + it('should return true for deeply equal objects', () => { + expect(isEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe(true) + }) + + it('should return false for objects with different values', () => { + expect(isEqual({ a: 1 }, { a: 2 })).toBe(false) + }) + + it('should return false for objects with different keys', () => { + expect(isEqual({ a: 1 }, { b: 1 })).toBe(false) + }) + + it('should return true for equal arrays', () => { + expect(isEqual([1, 2, 3], [1, 2, 3])).toBe(true) + }) + + it('should return false for arrays with different lengths', () => { + expect(isEqual([1, 2], [1, 2, 3])).toBe(false) + }) + + it('should return false for arrays with different values', () => { + expect(isEqual([1, 2, 3], [1, 2, 4])).toBe(false) + }) + + it('should return false when comparing array to non-array', () => { + expect(isEqual([1], { 0: 1 })).toBe(false) + }) + + it('should return false for type mismatch', () => { + expect(isEqual(1, '1')).toBe(false) + }) +}) + +describe('diffMerge', () => { + it('should return undefined when there are no changes', () => { + const result = diffMerge({ a: 1, b: 'x' }, { a: 1, b: 'x' }) + expect(result).toBeUndefined() + }) + + it('should return changed primitive fields', () => { + const result = diffMerge({ a: 1, b: 2 }, { a: 1, b: 1 }) + expect(result).toEqual({ b: 2 }) + }) + + it('should include new fields not present in lastSent', () => { + const result = diffMerge({ a: 1, b: 2 }, { a: 1 }) + expect(result).toEqual({ b: 2 }) + }) + + it('should set null for deleted keys', () => { + const result = diffMerge({ a: 1 }, { a: 1, b: 2 }) + expect(result).toEqual({ b: null }) + }) + + it('should recursively diff nested objects', () => { + const result = diffMerge({ nested: { x: 1, y: 2 } }, { nested: { x: 1, y: 1 } }) + expect(result).toEqual({ nested: { y: 2 } }) + }) + + it('should return undefined for unchanged nested objects', () => { + const result = diffMerge({ nested: { x: 1 } }, { nested: { x: 1 } }) + expect(result).toBeUndefined() + }) + + it('should include new nested objects', () => { + const result = diffMerge({ nested: { x: 1 } }, {}) + expect(result).toEqual({ nested: { x: 1 } }) + }) + + describe('replaceKeys option', () => { + it('should use full replace strategy for specified keys', () => { + const result = diffMerge({ arr: [1, 2, 3] }, { arr: [1, 2] }, { replaceKeys: new Set(['arr']) }) + expect(result).toEqual({ arr: [1, 2, 3] }) + }) + + it('should not include replace key if unchanged', () => { + const result = diffMerge({ arr: [1, 2] }, { arr: [1, 2] }, { replaceKeys: new Set(['arr']) }) + expect(result).toBeUndefined() + }) + }) + + describe('appendKeys option', () => { + it('should append only new trailing elements for array keys', () => { + const result = diffMerge({ items: [1, 2, 3] }, { items: [1, 2] }, { appendKeys: new Set(['items']) }) + expect(result).toEqual({ items: [3] }) + }) + + it('should include full array when it first appears', () => { + const result = diffMerge({ items: [1, 2] }, {}, { appendKeys: new Set(['items']) }) + expect(result).toEqual({ items: [1, 2] }) + }) + + it('should not include append key if array has not grown', () => { + const result = diffMerge({ items: [1, 2] }, { items: [1, 2] }, { appendKeys: new Set(['items']) }) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/packages/rum-core/src/domain/view/viewDiff.ts b/packages/rum-core/src/domain/view/viewDiff.ts new file mode 100644 index 0000000000..c0182957d4 --- /dev/null +++ b/packages/rum-core/src/domain/view/viewDiff.ts @@ -0,0 +1,115 @@ +import { isIndexableObject } from '@datadog/browser-core' + +/** + * Compare two values for deep equality + */ +export function isEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true + } + + if (a === null || typeof a !== 'object' || b === null || typeof b !== 'object') { + return a === b + } + + // Arrays + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false + } + return a.every((val, idx) => isEqual(val, b[idx])) + } + + if (Array.isArray(a) || Array.isArray(b)) { + return false + } + + // Plain objects + const aObj = a as Record + const bObj = b as Record + const aKeys = Object.keys(aObj) + const bKeys = Object.keys(bObj) + + if (aKeys.length !== bKeys.length) { + return false + } + + return aKeys.every((key) => bKeys.includes(key) && isEqual(aObj[key], bObj[key])) +} + +/** + * Options for controlling diff merge behavior + */ +export interface DiffMergeOptions { + replaceKeys?: Set + appendKeys?: Set + ignoreKeys?: Set +} + +/** + * MERGE strategy: compare two objects and return an object with only changed fields. + * Returns undefined if no changes. + * + * Default strategy is REPLACE (isEqual check). Exceptions: + * - Both values are plain objects and key is not in replaceKeys: recurse (MERGE), sub-paths + * (e.g. 'view.custom_timings') are propagated to the recursive call. + * - Both values are arrays and key is in appendKeys: include only new trailing elements (APPEND) + */ +export function diffMerge( + current: Record, + lastSent: Record, + options?: DiffMergeOptions +): Record | undefined { + const result: Record = {} + const replaceKeys = options?.replaceKeys ?? new Set() + const appendKeys = options?.appendKeys ?? new Set() + const ignoreKeys = options?.ignoreKeys ?? new Set() + + for (const key of Object.keys(current)) { + if (ignoreKeys.has(key)) { + continue + } + + const currentVal = current[key] + const lastSentVal = lastSent[key] + + if (!replaceKeys.has(key) && isIndexableObject(currentVal) && isIndexableObject(lastSentVal)) { + // Both are plain objects and not marked for replace: recurse (MERGE) + const nestedDiff = diffMerge(currentVal, lastSentVal, { + replaceKeys: extractSubPaths(replaceKeys, key), + appendKeys: extractSubPaths(appendKeys, key), + ignoreKeys: extractSubPaths(ignoreKeys, key), + }) + if (nestedDiff) { + result[key] = nestedDiff + } + } else if (appendKeys.has(key) && Array.isArray(currentVal) && Array.isArray(lastSentVal)) { + // Array in appendKeys: include only new trailing elements (APPEND) + if (currentVal.length > lastSentVal.length) { + result[key] = currentVal.slice(lastSentVal.length) + } + } else if (!isEqual(currentVal, lastSentVal)) { + // Default: replace the whole value (REPLACE) + result[key] = currentVal + } + } + + // Deleted keys: present in lastSent but not in current + for (const key of Object.keys(lastSent)) { + if (!(key in current)) { + result[key] = null + } + } + + return Object.keys(result).length > 0 ? result : undefined +} + +function extractSubPaths(keys: Set, prefix: string): Set { + const result = new Set() + for (const key of keys) { + if (key.startsWith(`${prefix}.`)) { + result.add(key.slice(prefix.length + 1)) + } + } + return result +} diff --git a/packages/rum-core/src/rawRumEvent.types.ts b/packages/rum-core/src/rawRumEvent.types.ts index d70e207940..fcb173b83a 100644 --- a/packages/rum-core/src/rawRumEvent.types.ts +++ b/packages/rum-core/src/rawRumEvent.types.ts @@ -18,6 +18,7 @@ import type { RumLongTaskEvent, RumResourceEvent, RumViewEvent, + RumViewUpdateEvent, RumVitalEvent, } from './rumEvent.types' @@ -26,6 +27,7 @@ export const RumEventType = { ERROR: 'error', LONG_TASK: 'long_task', VIEW: 'view', + VIEW_UPDATE: 'view_update', RESOURCE: 'resource', VITAL: 'vital', } as const @@ -34,6 +36,7 @@ export type RumEventType = (typeof RumEventType)[keyof typeof RumEventType] export type AssembledRumEvent = ( | RumViewEvent + | RumViewUpdateEvent | RumActionEvent | RumResourceEvent | RumErrorEvent @@ -183,6 +186,19 @@ export interface RawRumViewEvent { } } +export interface RawRumViewUpdateEvent { + date: TimeStamp + type: typeof RumEventType.VIEW_UPDATE + view: Partial + _dd: Partial & { + document_version: number + } + display?: Partial + privacy?: RawRumViewEvent['privacy'] + device?: RawRumViewEvent['device'] + feature_flags?: Context +} + interface ViewDisplay { scroll: { max_depth?: number @@ -410,6 +426,7 @@ export type RawRumEvent = | RawRumErrorEvent | RawRumResourceEvent | RawRumViewEvent + | RawRumViewUpdateEvent | RawRumLongTaskEvent | RawRumLongAnimationFrameEvent | RawRumActionEvent diff --git a/packages/rum-core/src/transport/startRumBatch.spec.ts b/packages/rum-core/src/transport/startRumBatch.spec.ts new file mode 100644 index 0000000000..16ddd31f87 --- /dev/null +++ b/packages/rum-core/src/transport/startRumBatch.spec.ts @@ -0,0 +1,227 @@ +import { ExperimentalFeature, addExperimentalFeatures, isExperimentalFeatureEnabled } from '@datadog/browser-core' +import { resetExperimentalFeatures } from '@datadog/browser-core/src/tools/experimentalFeatures' +import { registerCleanupTask } from '@datadog/browser-core/test' +import type { AssembledRumEvent } from '../rawRumEvent.types' +import { RumEventType } from '../rawRumEvent.types' +import { computeAssembledViewDiff, PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL } from './startRumBatch' + +function makeAssembledView(overrides: Record = {}): AssembledRumEvent { + return { + type: RumEventType.VIEW, + date: 1000, + application: { id: 'app-1' }, + session: { id: 'sess-1', type: 'user' }, + view: { + id: 'view-1', + name: 'Home', + url: '/home', + referrer: '', + is_active: true, + action: { count: 0 }, + error: { count: 0 }, + long_task: { count: 0 }, + resource: { count: 0 }, + time_spent: 0, + }, + _dd: { + document_version: 1, + format_version: 2, + sdk_name: 'rum', + configuration: { start_session_replay_recording_manually: false }, + }, + service: 'my-service', + version: '1.0.0', + ddtags: 'env:prod', + source: 'browser', + context: {}, + ...overrides, + } as unknown as AssembledRumEvent +} + +describe('computeAssembledViewDiff', () => { + it('should return undefined when nothing has changed', () => { + const last = makeAssembledView() + const current = makeAssembledView({ + _dd: { + document_version: 2, + format_version: 2, + sdk_name: 'rum', + configuration: { start_session_replay_recording_manually: false }, + }, + }) + const result = computeAssembledViewDiff(current, last) + + // Only document_version changed (always required, not a "meaningful change") + // view.* unchanged → should return undefined + expect(result).toBeUndefined() + }) + + it('should always include required routing fields', () => { + const last = makeAssembledView() + const current = makeAssembledView({ + _dd: { + document_version: 2, + format_version: 2, + sdk_name: 'rum', + configuration: { start_session_replay_recording_manually: false }, + }, + view: { + id: 'view-1', + name: 'Home', + url: '/home', + referrer: '', + is_active: true, + action: { count: 1 }, + error: { count: 0 }, + long_task: { count: 0 }, + resource: { count: 0 }, + time_spent: 100, + }, + }) + const result = computeAssembledViewDiff(current, last)! + + expect(result.type).toBe(RumEventType.VIEW_UPDATE) + expect((result as any).application).toEqual({ id: 'app-1' }) + expect((result as any).session).toEqual({ id: 'sess-1', type: 'user' }) + expect((result.view as any).id).toBe('view-1') + expect((result.view as any).url).toBe('/home') + expect((result._dd as any).document_version).toBe(2) + expect((result._dd as any).format_version).toBe(2) + }) + + it('should include only changed view.* fields', () => { + const last = makeAssembledView() + const current = makeAssembledView({ + _dd: { + document_version: 2, + format_version: 2, + sdk_name: 'rum', + configuration: { start_session_replay_recording_manually: false }, + }, + view: { + id: 'view-1', + name: 'Home', + url: '/home', + referrer: '', + is_active: true, + action: { count: 3 }, + error: { count: 0 }, + long_task: { count: 0 }, + resource: { count: 0 }, + time_spent: 5000, + }, + }) + const result = computeAssembledViewDiff(current, last)! + + expect((result.view as any).action).toEqual({ count: 3 }) // changed + expect((result.view as any).time_spent).toBe(5000) // changed + expect((result.view as any).error).toBeUndefined() // unchanged, stripped + expect((result.view as any).name).toBeUndefined() // unchanged, stripped + expect((result.view as any).url).toBe('/home') // required routing field, always present + }) + + it('should strip unchanged top-level assembled fields', () => { + const last = makeAssembledView({ service: 'svc', version: '1.0.0' }) + const current = makeAssembledView({ + _dd: { + document_version: 2, + format_version: 2, + sdk_name: 'rum', + configuration: { start_session_replay_recording_manually: false }, + }, + view: { + id: 'view-1', + name: 'Home', + url: '/home', + referrer: '', + is_active: true, + action: { count: 1 }, + error: { count: 0 }, + long_task: { count: 0 }, + resource: { count: 0 }, + time_spent: 100, + }, + service: 'svc', + version: '1.0.0', + }) + const result = computeAssembledViewDiff(current, last)! + + expect(result.service).toBeUndefined() // unchanged, stripped + expect((result as any).version).toBeUndefined() // unchanged, stripped + }) + + it('should keep top-level assembled fields that changed', () => { + const last = makeAssembledView({ service: 'old-service' }) + const current = makeAssembledView({ + _dd: { + document_version: 2, + format_version: 2, + sdk_name: 'rum', + configuration: { start_session_replay_recording_manually: false }, + }, + view: { + id: 'view-1', + name: 'Home', + url: '/home', + referrer: '', + is_active: true, + action: { count: 1 }, + error: { count: 0 }, + long_task: { count: 0 }, + resource: { count: 0 }, + time_spent: 100, + }, + service: 'new-service', + }) + const result = computeAssembledViewDiff(current, last)! + + expect(result.service).toBe('new-service') + }) + + it('should not mutate the input events', () => { + const last = makeAssembledView() + const current = makeAssembledView({ + _dd: { + document_version: 2, + format_version: 2, + sdk_name: 'rum', + configuration: { start_session_replay_recording_manually: false }, + }, + view: { + id: 'view-1', + name: 'Home', + url: '/home', + referrer: '', + is_active: true, + action: { count: 1 }, + error: { count: 0 }, + long_task: { count: 0 }, + resource: { count: 0 }, + time_spent: 100, + }, + }) + const currentService = current.service + computeAssembledViewDiff(current, last) + + expect(current.service).toBe(currentService) + }) +}) + +describe('startRumBatch partial_view_updates routing', () => { + beforeEach(() => { + addExperimentalFeatures([ExperimentalFeature.PARTIAL_VIEW_UPDATES]) + registerCleanupTask(resetExperimentalFeatures) + }) + + it('PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL should be 100', () => { + expect(PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL).toBe(100) + }) + + it('PARTIAL_VIEW_UPDATES_NO_CHECKPOINT flag should be defined', () => { + expect(ExperimentalFeature.PARTIAL_VIEW_UPDATES_NO_CHECKPOINT).toBe('partial_view_updates_no_checkpoint') + }) + + it('PARTIAL_VIEW_UPDATES_NO_CHECKPOINT flag should be disabled by default', () => { + expect(isExperimentalFeatureEnabled(ExperimentalFeature.PARTIAL_VIEW_UPDATES_NO_CHECKPOINT)).toBe(false) + }) +}) diff --git a/packages/rum-core/src/transport/startRumBatch.ts b/packages/rum-core/src/transport/startRumBatch.ts index d4e6b31920..f91d51c717 100644 --- a/packages/rum-core/src/transport/startRumBatch.ts +++ b/packages/rum-core/src/transport/startRumBatch.ts @@ -1,10 +1,71 @@ import type { Observable, RawError, PageMayExitEvent, Encoder } from '@datadog/browser-core' -import { createBatch, createFlushController, createHttpRequest, DeflateEncoderStreamId } from '@datadog/browser-core' +import { + combine, + createBatch, + createFlushController, + createHttpRequest, + DeflateEncoderStreamId, + isExperimentalFeatureEnabled, + ExperimentalFeature, + sendToExtension, +} from '@datadog/browser-core' import type { RumConfiguration } from '../domain/configuration' import type { LifeCycle } from '../domain/lifeCycle' import { LifeCycleEventType } from '../domain/lifeCycle' import type { AssembledRumEvent } from '../rawRumEvent.types' +import type { RumViewEvent } from '../rumEvent.types' import { RumEventType } from '../rawRumEvent.types' +import { diffMerge } from '../domain/view/viewDiff' + +export const PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL = 100 + +export function computeAssembledViewDiff( + current: AssembledRumEvent, + last: AssembledRumEvent +): AssembledRumEvent | undefined { + const currentObj = current as unknown as Record + const lastObj = last as unknown as Record + + const diff = diffMerge(currentObj, lastObj, { + // context, connectivity, usr, device, privacy are objects — use REPLACE to avoid partial updates + replaceKeys: new Set(['view.custom_timings', 'context', 'connectivity', 'usr', 'device', 'privacy']), + appendKeys: new Set(['_dd.page_states']), + // Ignore always-required fields — they are added back via combine regardless of changes + ignoreKeys: new Set([ + 'date', + 'type', + 'application', + 'session', + 'view.id', + 'view.url', + '_dd.document_version', + '_dd.format_version', + ]), + }) + + if (!diff) { + return undefined + } + + const currentView = currentObj.view as Record + const currentDd = currentObj._dd as Record + + // Merge always-required fields on top of the diff for backend routing + return combine(diff, { + type: RumEventType.VIEW_UPDATE, + date: currentObj.date, + application: currentObj.application, + session: currentObj.session, + view: { + id: currentView.id, + url: currentView.url, + }, + _dd: { + document_version: currentDd.document_version, + format_version: currentDd.format_version, + }, + }) as unknown as AssembledRumEvent +} export function startRumBatch( configuration: RumConfiguration, @@ -28,12 +89,63 @@ export function startRumBatch( }), }) + let lastSentView: AssembledRumEvent | undefined + let viewUpdatesSinceCheckpoint = 0 + lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (serverRumEvent: AssembledRumEvent) => { - if (serverRumEvent.type === RumEventType.VIEW) { - batch.upsert(serverRumEvent, serverRumEvent.view.id) - } else { + if (serverRumEvent.type !== RumEventType.VIEW) { + // Non-view events: always append batch.add(serverRumEvent) + return + } + + if (!isExperimentalFeatureEnabled(ExperimentalFeature.PARTIAL_VIEW_UPDATES)) { + // Feature OFF: existing behavior — upsert full view + batch.upsert(serverRumEvent, serverRumEvent.view.id) + return + } + + const viewId = serverRumEvent.view.id + + // New view started + if (viewId !== lastSentView?.view.id) { + lastSentView = serverRumEvent + viewUpdatesSinceCheckpoint = 0 + batch.upsert(serverRumEvent, viewId) + return + } + + // View ended (is_active: false) + if (!(serverRumEvent as RumViewEvent).view.is_active) { + lastSentView = undefined + viewUpdatesSinceCheckpoint = 0 + batch.upsert(serverRumEvent, viewId) + return + } + + // Checkpoint: every N intermediate updates, send a full view (unless disabled by flag) + if (!isExperimentalFeatureEnabled(ExperimentalFeature.PARTIAL_VIEW_UPDATES_NO_CHECKPOINT)) { + viewUpdatesSinceCheckpoint += 1 + if (viewUpdatesSinceCheckpoint >= PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL) { + viewUpdatesSinceCheckpoint = 0 + lastSentView = serverRumEvent + batch.upsert(serverRumEvent, viewId) + return + } + } + + // Intermediate update: compute diff and send view_update. + // Note: view_update events are created here, post-assembly, and go directly to batch.add(). + // They intentionally bypass RAW_RUM_EVENT_COLLECTED → assembly → RUM_EVENT_COLLECTED, which + // means they skip beforeSend entirely. view_update is an internal bandwidth optimization — + // not a customer-visible event type, and not modifiable via beforeSend. + const diff = computeAssembledViewDiff(serverRumEvent, lastSentView) + lastSentView = serverRumEvent + if (diff) { + sendToExtension('rum', diff) + batch.add(diff) } + // If diff is undefined (nothing changed), skip — no event emitted }) return batch diff --git a/packages/rum-core/test/fixtures.ts b/packages/rum-core/test/fixtures.ts index d5c15aceff..c450ab3fa2 100644 --- a/packages/rum-core/test/fixtures.ts +++ b/packages/rum-core/test/fixtures.ts @@ -119,6 +119,18 @@ export function createRawRumEvent(type: RumEventType, overrides?: Context): RawR }, overrides ) + case RumEventType.VIEW_UPDATE: + return combine( + { + type, + date: 0 as TimeStamp, + view: {}, + _dd: { + document_version: 1, + }, + }, + overrides + ) } } diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index 482ca2b981..cf7974b8db 100644 --- a/test/e2e/scenario/rum/actions.scenario.ts +++ b/test/e2e/scenario/rum/actions.scenario.ts @@ -233,8 +233,8 @@ test.describe('action collection', () => { const viewEvents = intakeRegistry.rumViewEvents const originalViewEvent = viewEvents.find((view) => view.view.url.endsWith('/'))! const otherViewEvent = viewEvents.find((view) => view.view.url.endsWith('/other-view'))! - expect(originalViewEvent.view.action.count).toBe(1) - expect(otherViewEvent.view.action.count).toBe(0) + expect(originalViewEvent.view.action?.count).toBe(1) + expect(otherViewEvent.view.action?.count).toBe(0) }) createTest('collect an "error click"') diff --git a/test/e2e/scenario/rum/partialViewUpdates.scenario.ts b/test/e2e/scenario/rum/partialViewUpdates.scenario.ts new file mode 100644 index 0000000000..b806ff6a43 --- /dev/null +++ b/test/e2e/scenario/rum/partialViewUpdates.scenario.ts @@ -0,0 +1,208 @@ +import { test, expect } from '@playwright/test' +import { createTest, html, waitForRequests } from '../../lib/framework' +import type { IntakeRegistry } from '../../lib/framework' + +// Loose type for view_update events received at the intake (no generated schema type yet) +interface ViewUpdateEvent { + type: string + date: number + application: { id: string } + session: { id: string } + view: { id: string; is_active?: boolean; [key: string]: unknown } + _dd: { document_version: number; [key: string]: unknown } + [key: string]: unknown +} + +// Helper: extract view_update events from all RUM events +// (intakeRegistry.rumViewEvents only returns type==='view') +function getViewUpdateEvents(intakeRegistry: IntakeRegistry): ViewUpdateEvent[] { + return intakeRegistry.rumEvents.filter((e) => (e.type as string) === 'view_update') as unknown as ViewUpdateEvent[] +} + +test.describe('partial view updates', () => { + createTest('should send view_update events after the initial view event') + .withRum({ + enableExperimentalFeatures: ['partial_view_updates'], + }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + // Trigger a user action to cause a view update with changed metrics + await page.evaluate(() => { + window.DD_RUM!.addAction('test-action') + }) + + await flushEvents() + + // First event should be type 'view' + const viewEvents = intakeRegistry.rumViewEvents + expect(viewEvents.length).toBeGreaterThanOrEqual(1) + expect(viewEvents[0].type).toBe('view') + + // Should have at least one view_update + const viewUpdateEvents = getViewUpdateEvents(intakeRegistry) + expect(viewUpdateEvents.length).toBeGreaterThanOrEqual(1) + + // All events share the same view.id + const viewId = viewEvents[0].view.id + for (const update of viewUpdateEvents) { + expect(update.view.id).toBe(viewId) + } + }) + + createTest('should have monotonically increasing document_version across view and view_update events') + .withRum({ + enableExperimentalFeatures: ['partial_view_updates'], + }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + window.DD_RUM!.addAction('test-action') + }) + + await flushEvents() + + // Collect all view-related events (view + view_update) sorted by document_version + const allViewRelatedEvents = [ + ...intakeRegistry.rumViewEvents.map((e) => ({ _dd: e._dd })), + ...getViewUpdateEvents(intakeRegistry).map((e) => ({ _dd: e._dd })), + ].sort((a, b) => a._dd.document_version - b._dd.document_version) + + expect(allViewRelatedEvents.length).toBeGreaterThanOrEqual(2) + + // Verify monotonic increase + for (let i = 1; i < allViewRelatedEvents.length; i++) { + expect(allViewRelatedEvents[i]._dd.document_version).toBeGreaterThan( + allViewRelatedEvents[i - 1]._dd.document_version + ) + } + }) + + createTest('should only send view events when feature flag is not enabled') + .withRum() + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + window.DD_RUM!.addAction('test-action') + }) + + await flushEvents() + + // Should have view events + expect(intakeRegistry.rumViewEvents.length).toBeGreaterThanOrEqual(1) + + // Should NOT have any view_update events + const viewUpdateEvents = getViewUpdateEvents(intakeRegistry) + expect(viewUpdateEvents).toHaveLength(0) + }) + + createTest('should emit a new full view event after navigation') + .withRum({ + enableExperimentalFeatures: ['partial_view_updates'], + }) + .withBody(html` + Navigate + + `) + .run(async ({ intakeRegistry, flushEvents, page }) => { + // Trigger a route change to create a new view + await page.click('#nav-link') + + await flushEvents() + + // Should have at least 2 full view events (one per view.id) + const viewEvents = intakeRegistry.rumViewEvents + expect(viewEvents.length).toBeGreaterThanOrEqual(2) + + // The two view events should have different view.ids + const viewIds = new Set(viewEvents.map((e) => e.view.id)) + expect(viewIds.size).toBeGreaterThanOrEqual(2) + }) + + createTest('should include required fields in all view_update events') + .withRum({ + enableExperimentalFeatures: ['partial_view_updates'], + }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + await page.evaluate(() => { + window.DD_RUM!.addAction('test-action') + }) + + await flushEvents() + + const viewUpdateEvents = getViewUpdateEvents(intakeRegistry) + expect(viewUpdateEvents.length).toBeGreaterThanOrEqual(1) + + for (const event of viewUpdateEvents) { + // Required fields per spec FR-3 + expect(event.type).toBe('view_update') + expect(event.application.id).toBeDefined() + expect(event.session.id).toBeDefined() + expect(event.view.id).toBeDefined() + expect(event._dd.document_version).toBeDefined() + expect(event.date).toBeDefined() + } + }) + + createTest('should send a full VIEW event (not view_update) with is_active false when view ends') + .withRum({ + enableExperimentalFeatures: ['partial_view_updates'], + }) + .withBody(html` + Navigate + + `) + .run(async ({ intakeRegistry, flushEvents, page }) => { + // Navigate to trigger view end on the first view + await page.click('#nav-link') + + await flushEvents() + + // After Fix 3: view-end emits a full VIEW event, not a VIEW_UPDATE + const viewEvents = intakeRegistry.rumViewEvents + const firstViewId = viewEvents[0].view.id + const endEvent = viewEvents.find((e) => e.view.id === firstViewId && !e.view.is_active) + expect(endEvent).toBeDefined() + expect(endEvent?.type).toBe('view') + + // No view_update should have is_active: false + const viewUpdateEvents = getViewUpdateEvents(intakeRegistry) + const endUpdateEvent = viewUpdateEvents.find((e) => e.view.id === firstViewId && e.view.is_active === false) + expect(endUpdateEvent).toBeUndefined() + }) + + createTest('should emit a full view checkpoint event during a long-lived view') + .withRum({ + enableExperimentalFeatures: ['partial_view_updates'], + }) + .run(async ({ intakeRegistry, flushEvents, page }) => { + // Flush the initial view first so it arrives at the intake in its own batch. + // The checkpoint (every 100 updates) uses batch.upsert with the same viewId which would + // replace the initial view if they share a batch — flushing first prevents that. + // Dispatching beforeunload triggers the SDK batch send without navigating away. + await page.evaluate(() => window.dispatchEvent(new Event('beforeunload'))) + await waitForRequests(page) + + // Use setViewName to trigger unthrottled view updates (unlike addAction which is + // throttled to THROTTLE_VIEW_UPDATE_PERIOD=3s, setViewName calls triggerViewUpdate directly). + // We need more than PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL (100) updates to trigger a checkpoint. + // All calls are batched in a single evaluate to avoid 102 round-trips to the browser. + await page.evaluate((count) => { + for (let i = 0; i < count; i++) { + window.DD_RUM!.setViewName(`step-${i}`) + } + }, 102) + + await flushEvents() + + // There must be at least 2 full VIEW events for the same view.id + // (the initial one + at least one checkpoint) + const firstViewId = intakeRegistry.rumViewEvents[0].view.id + const fullViewsForFirstView = intakeRegistry.rumViewEvents.filter((e) => e.view.id === firstViewId) + expect(fullViewsForFirstView.length).toBeGreaterThanOrEqual(2) + }) +})