diff --git a/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx b/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx index 106ce30f71..26eb74dbd2 100644 --- a/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx +++ b/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx @@ -11,6 +11,7 @@ import type { RumLongTaskEvent, RumResourceEvent, RumViewEvent, + RumViewUpdateEvent, RumVitalEvent, } from '../../../../../../packages/rum-core/src/rumEvent.types' import type { SdkEvent } from '../../../sdkEvent' @@ -31,6 +32,7 @@ const RUM_EVENT_TYPE_COLOR = { error: 'red', long_task: 'yellow', view: 'blue', + view_update: 'blue', resource: 'cyan', telemetry: 'teal', vital: 'orange', @@ -287,6 +289,8 @@ export const EventDescription = React.memo(({ event }: { event: SdkEvent }) => { switch (event.type) { case 'view': return + case 'view_update': + return case 'long_task': return case 'error': @@ -337,6 +341,34 @@ function ViewDescription({ event }: { event: RumViewEvent }) { ) } +// view.id is the only field always present in a view_update diff (routing field) +const VIEW_UPDATE_REQUIRED_KEYS = new Set(['id']) + +function ViewUpdateDescription({ event }: { event: RumViewUpdateEvent }) { + const changedFieldCount = event.view + ? Object.keys(event.view).filter((k) => !VIEW_UPDATE_REQUIRED_KEYS.has(k)).length + : 0 + const viewName = event.view ? event.view.name || event.view.url : undefined + + return ( + <> + View update v{event._dd.document_version} + {viewName && ( + <> + {' '} + · {viewName} + + )} + {changedFieldCount > 0 && ( + <> + {' '} + · {changedFieldCount} {changedFieldCount === 1 ? 'field' : 'fields'} changed + + )} + + ) +} + function ActionDescription({ event }: { event: RumActionEvent }) { const actionName = event.action.target?.name const frustrationTypes = event.action.frustration?.type @@ -424,5 +456,12 @@ function Emphasis({ children }: { children: ReactNode }) { } function getViewName(view: { name?: string; url: string }) { - return `${view.name || new URL(view.url).pathname}` + if (view.name) { + return view.name + } + try { + return new URL(view.url).pathname + } catch { + return view.url + } } diff --git a/developer-extension/src/panel/hooks/useEvents/eventCollection.ts b/developer-extension/src/panel/hooks/useEvents/eventCollection.ts index cabe19636f..c8be0211e0 100644 --- a/developer-extension/src/panel/hooks/useEvents/eventCollection.ts +++ b/developer-extension/src/panel/hooks/useEvents/eventCollection.ts @@ -37,7 +37,7 @@ export function startEventCollection(strategy: EventCollectionStrategy, onEvents function compareEvents(a: SdkEvent, b: SdkEvent) { // Sort events chronologically if (a.date !== b.date) { - return b.date - a.date + return (b.date ?? 0) - (a.date ?? 0) } // If two events have the same date, make sure to display View events last. This ensures that View diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b2a3d034d8..4778a4a8a2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ export { TrackingConsent, createTrackingConsentState } from './domain/trackingCo export { isExperimentalFeatureEnabled, addExperimentalFeatures, + resetExperimentalFeatures, getExperimentalFeatures, initFeatureFlags, ExperimentalFeature, diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts index 43fb69f9fc..7658098887 100644 --- a/packages/core/src/tools/experimentalFeatures.ts +++ b/packages/core/src/tools/experimentalFeatures.ts @@ -25,6 +25,7 @@ export enum ExperimentalFeature { LCP_SUBPARTS = 'lcp_subparts', INP_SUBPARTS = 'inp_subparts', TOO_MANY_REQUESTS_INVESTIGATION = 'too_many_requests_investigation', + PARTIAL_VIEW_UPDATES = 'partial_view_updates', } 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 d0452bd27f..4dc18960aa 100644 --- a/packages/rum-core/src/domain/assembly.spec.ts +++ b/packages/rum-core/src/domain/assembly.spec.ts @@ -336,6 +336,54 @@ describe('rum assembly', () => { }) }) + it('should not allow dismissing view_update events', () => { + const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ + partialConfiguration: { + beforeSend: () => false, + }, + }) + + const displaySpy = spyOn(display, 'warn') + notifyRawRumEvent(lifeCycle, { + rawRumEvent: createRawRumEvent(RumEventType.VIEW_UPDATE), + }) + + expect(serverRumEvents.length).toBe(1) + expect(displaySpy).toHaveBeenCalledWith("Can't dismiss view events using beforeSend!") + }) + + it('should call beforeSend with view_update type and only the changed view fields', () => { + let capturedEvent: RumEvent | undefined + const { lifeCycle } = setupAssemblyTestWithDefaults({ + partialConfiguration: { + beforeSend: (event) => { + capturedEvent = event + }, + }, + }) + + notifyRawRumEvent(lifeCycle, { + rawRumEvent: createRawRumEvent(RumEventType.VIEW_UPDATE, { + view: { + action: { count: 5 }, + error: { count: 2 }, + }, + _dd: { document_version: 3 }, + }), + }) + + // beforeSend receives view_update, not view — the event type changed with partial_view_updates + expect(capturedEvent!.type).toBe('view_update') + // Only the specific changed fields from the raw diff are present + expect((capturedEvent!.view as any).action?.count).toBe(5) + expect((capturedEvent!.view as any).error?.count).toBe(2) + // Fields not in the diff are absent (unlike a full view event which would have all fields) + expect((capturedEvent!.view as any).is_active).toBeUndefined() + expect((capturedEvent!.view as any).resource).toBeUndefined() + // Session context is still fully populated by the assembly pipeline + expect(capturedEvent!.session.id).toBe('1234') + }) + it('should not dismiss when true is returned', () => { const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ partialConfiguration: { diff --git a/packages/rum-core/src/domain/assembly.ts b/packages/rum-core/src/domain/assembly.ts index e1b32dfc1c..2c43726eb3 100644 --- a/packages/rum-core/src/domain/assembly.ts +++ b/packages/rum-core/src/domain/assembly.ts @@ -49,6 +49,12 @@ export function startRumAssembly( ...VIEW_MODIFIABLE_FIELD_PATHS, ...ROOT_MODIFIABLE_FIELD_PATHS, }, + [RumEventType.VIEW_UPDATE]: { + 'view.performance.lcp.resource_url': 'string', + ...USER_CUSTOMIZABLE_FIELD_PATHS, + ...VIEW_MODIFIABLE_FIELD_PATHS, + ...ROOT_MODIFIABLE_FIELD_PATHS, + }, [RumEventType.ERROR]: { 'error.message': 'string', 'error.stack': 'string', @@ -129,7 +135,8 @@ function shouldSend( const result = limitModification(event, modifiableFieldPathsByEvent[event.type], (event) => beforeSend(event, domainContext) ) - if (result === false && event.type !== RumEventType.VIEW) { + const eventType = event.type as RumEventType + if (result === false && eventType !== RumEventType.VIEW && eventType !== RumEventType.VIEW_UPDATE) { return false } if (result === false) { diff --git a/packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts b/packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts index bca87d705f..eba9e54dde 100644 --- a/packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts @@ -28,7 +28,7 @@ describe('featureFlagContexts', () => { }) describe('assemble hook', () => { - it('should add feature flag evaluations on VIEW and ERROR by default ', () => { + it('should add feature flag evaluations on VIEW, VIEW_UPDATE, and ERROR by default ', () => { lifeCycle.notify(LifeCycleEventType.BEFORE_VIEW_CREATED, { startClocks: relativeToClocks(0 as RelativeTime), } as ViewCreatedEvent) @@ -39,6 +39,10 @@ describe('featureFlagContexts', () => { eventType: 'view', startTime: 0 as RelativeTime, } as AssembleHookParams) + const defaultViewUpdateAttributes = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view_update' as any, + startTime: 0 as RelativeTime, + } as AssembleHookParams) const defaultErrorAttributes = hooks.triggerHook(HookNames.Assemble, { eventType: 'error', startTime: 0 as RelativeTime, @@ -51,6 +55,14 @@ describe('featureFlagContexts', () => { }, }) + expect(defaultViewUpdateAttributes).toEqual( + jasmine.objectContaining({ + feature_flags: { + feature: 'foo', + }, + }) + ) + expect(defaultErrorAttributes).toEqual({ type: 'error', feature_flags: { diff --git a/packages/rum-core/src/domain/contexts/featureFlagContext.ts b/packages/rum-core/src/domain/contexts/featureFlagContext.ts index b2b1d92fad..9f7f47bd17 100644 --- a/packages/rum-core/src/domain/contexts/featureFlagContext.ts +++ b/packages/rum-core/src/domain/contexts/featureFlagContext.ts @@ -43,6 +43,7 @@ export function startFeatureFlagContexts( hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | SKIPPED => { const trackFeatureFlagsForEvents = (configuration.trackFeatureFlagsForEvents as RumEventType[]).concat([ RumEventType.VIEW, + RumEventType.VIEW_UPDATE, RumEventType.ERROR, ]) if (!trackFeatureFlagsForEvents.includes(eventType as RumEventType)) { 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..455b9a548b --- /dev/null +++ b/packages/rum-core/src/domain/view/viewDiff.ts @@ -0,0 +1,145 @@ +import { isEmptyObject } from '@datadog/browser-core' + +/** + * Compare two values for deep equality + */ +export function isEqual(a: unknown, b: unknown): boolean { + // Reference equality + if (a === b) { + return true + } + + // Handle null/undefined + if (a === null || b === null || a === undefined || b === undefined) { + return a === b + } + + // Type mismatch + if (typeof a !== typeof b) { + return false + } + + // Primitives + if (typeof a !== '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])) + } + + // One is array, other is not + if (Array.isArray(a) || Array.isArray(b)) { + return false + } + + // 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 +} + +/** + * MERGE strategy: compare two objects and return an object with only changed fields. + * Returns undefined if no changes. + */ +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() + + // Check all keys in current + for (const key of Object.keys(current)) { + const currentVal = current[key] + const lastSentVal = lastSent[key] + + // REPLACE strategy for specific keys + if (replaceKeys.has(key)) { + if (!isEqual(currentVal, lastSentVal)) { + result[key] = currentVal + } + continue + } + + // APPEND strategy for array keys + if (appendKeys.has(key)) { + if (Array.isArray(currentVal) && Array.isArray(lastSentVal)) { + if (currentVal.length > lastSentVal.length) { + // Include only new trailing elements + result[key] = currentVal.slice(lastSentVal.length) + } + } else if (Array.isArray(currentVal) && !lastSentVal) { + // Array appeared for the first time + result[key] = currentVal + } + continue + } + + // Primitive comparison + if (currentVal !== null && typeof currentVal !== 'object') { + if (currentVal !== lastSentVal) { + result[key] = currentVal + } + continue + } + + // Handle null explicitly + if (currentVal === null) { + if (currentVal !== lastSentVal) { + result[key] = currentVal + } + continue + } + + // Array comparison (not in appendKeys) + if (Array.isArray(currentVal)) { + if (!isEqual(currentVal, lastSentVal)) { + result[key] = currentVal + } + continue + } + + // Object comparison - recurse (no options propagation: replaceKeys/appendKeys apply only at top level) + if (typeof currentVal === 'object' && lastSentVal && typeof lastSentVal === 'object') { + const nestedDiff = diffMerge(currentVal as Record, lastSentVal as Record) + if (nestedDiff && !isEmptyObject(nestedDiff)) { + result[key] = nestedDiff + } + } else if (typeof currentVal === 'object' && !lastSentVal) { + // New object appeared + result[key] = currentVal + } + } + + // Check for 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 +} diff --git a/packages/rum-core/src/rawRumEvent.types.ts b/packages/rum-core/src/rawRumEvent.types.ts index ccb4aae485..d11982ba90 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 @@ -171,6 +174,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 @@ -397,6 +413,7 @@ export type RawRumEvent = | RawRumErrorEvent | RawRumResourceEvent | RawRumViewEvent + | RawRumViewUpdateEvent | RawRumLongTaskEvent | RawRumLongAnimationFrameEvent | RawRumActionEvent diff --git a/packages/rum-core/src/rumEvent.types.ts b/packages/rum-core/src/rumEvent.types.ts index b71348baa5..a99bf6c7fe 100644 --- a/packages/rum-core/src/rumEvent.types.ts +++ b/packages/rum-core/src/rumEvent.types.ts @@ -12,6 +12,7 @@ export type RumEvent = | RumLongTaskEvent | RumResourceEvent | RumViewEvent + | RumViewUpdateEvent | RumVitalEvent /** * Schema of all properties of an Action event @@ -956,317 +957,23 @@ export type RumResourceEvent = CommonProperties & */ export type RumViewEvent = CommonProperties & ViewContainerSchema & - StreamSchema & { + StreamSchema & + ViewProperties & { /** * RUM event type */ readonly type: 'view' - /** - * View properties - */ - readonly view: { - /** - * Duration in ns to the view is considered loaded - */ - readonly loading_time?: number - /** - * Duration in ns from the moment the view was started until all the initial network requests settled - */ - readonly network_settled_time?: number - /** - * Duration in ns to from the last interaction on previous view to the moment the current view was displayed - */ - readonly interaction_to_next_view_time?: number - /** - * Type of the loading of the view - */ - readonly loading_type?: - | 'initial_load' - | 'route_change' - | 'activity_display' - | 'activity_redisplay' - | 'fragment_display' - | 'fragment_redisplay' - | 'view_controller_display' - | 'view_controller_redisplay' - /** - * Time spent on the view in ns - */ - readonly time_spent: number - /** - * @deprecated - * Duration in ns to the first rendering (deprecated in favor of `view.performance.fcp.timestamp`) - */ - readonly first_contentful_paint?: number - /** - * @deprecated - * Duration in ns to the largest contentful paint (deprecated in favor of `view.performance.lcp.timestamp`) - */ - readonly largest_contentful_paint?: number - /** - * @deprecated - * CSS selector path of the largest contentful paint element (deprecated in favor of `view.performance.lcp.target_selector`) - */ - readonly largest_contentful_paint_target_selector?: string - /** - * @deprecated - * Duration in ns of the first input event delay (deprecated in favor of `view.performance.fid.duration`) - */ - readonly first_input_delay?: number - /** - * @deprecated - * Duration in ns to the first input (deprecated in favor of `view.performance.fid.timestamp`) - */ - readonly first_input_time?: number - /** - * @deprecated - * CSS selector path of the first input target element (deprecated in favor of `view.performance.fid.target_selector`) - */ - readonly first_input_target_selector?: string - /** - * @deprecated - * Longest duration in ns between an interaction and the next paint (deprecated in favor of `view.performance.inp.duration`) - */ - readonly interaction_to_next_paint?: number - /** - * @deprecated - * Duration in ns between start of the view and start of the INP (deprecated in favor of `view.performance.inp.timestamp`) - */ - readonly interaction_to_next_paint_time?: number - /** - * @deprecated - * CSS selector path of the interacted element corresponding to INP (deprecated in favor of `view.performance.inp.target_selector`) - */ - readonly interaction_to_next_paint_target_selector?: string - /** - * @deprecated - * Total layout shift score that occurred on the view (deprecated in favor of `view.performance.cls.score`) - */ - readonly cumulative_layout_shift?: number - /** - * @deprecated - * Duration in ns between start of the view and start of the largest layout shift contributing to CLS (deprecated in favor of `view.performance.cls.timestamp`) - */ - readonly cumulative_layout_shift_time?: number - /** - * @deprecated - * CSS selector path of the first element (in document order) of the largest layout shift contributing to CLS (deprecated in favor of `view.performance.cls.target_selector`) - */ - readonly cumulative_layout_shift_target_selector?: string - /** - * Duration in ns to the complete parsing and loading of the document and its sub resources - */ - readonly dom_complete?: number - /** - * Duration in ns to the complete parsing and loading of the document without its sub resources - */ - readonly dom_content_loaded?: number - /** - * Duration in ns to the end of the parsing of the document - */ - readonly dom_interactive?: number - /** - * Duration in ns to the end of the load event handler execution - */ - readonly load_event?: number - /** - * Duration in ns to the response start of the document request - */ - readonly first_byte?: number - /** - * User custom timings of the view. As timing name is used as facet path, it must contain only letters, digits, or the characters - _ . @ $ - */ - readonly custom_timings?: { - [k: string]: number - } - /** - * Whether the View corresponding to this event is considered active - */ - readonly is_active?: boolean - /** - * Whether the View had a low average refresh rate - */ - readonly is_slow_rendered?: boolean - /** - * Properties of the actions of the view - */ - readonly action: { - /** - * Number of actions that occurred on the view - */ - readonly count: number - [k: string]: unknown - } - /** - * Properties of the errors of the view - */ - readonly error: { - /** - * Number of errors that occurred on the view - */ - readonly count: number - [k: string]: unknown - } - /** - * Properties of the crashes of the view - */ - readonly crash?: { - /** - * Number of crashes that occurred on the view - */ - readonly count: number + view: { + time_spent: number + action: { [k: string]: unknown } - /** - * Properties of the long tasks of the view - */ - readonly long_task?: { - /** - * Number of long tasks that occurred on the view - */ - readonly count: number + error: { [k: string]: unknown } - /** - * Properties of the frozen frames of the view - */ - readonly frozen_frame?: { - /** - * Number of frozen frames that occurred on the view - */ - readonly count: number + resource: { [k: string]: unknown } - /** - * List of slow frames during the view’s lifetime - */ - readonly slow_frames?: { - /** - * Duration in ns between start of the view and the start of the slow frame - */ - readonly start: number - /** - * Duration in ns of the slow frame - */ - readonly duration: number - [k: string]: unknown - }[] - /** - * Properties of the resources of the view - */ - readonly resource: { - /** - * Number of resources that occurred on the view - */ - readonly count: number - [k: string]: unknown - } - /** - * Properties of the frustrations of the view - */ - readonly frustration?: { - /** - * Number of frustrations that occurred on the view - */ - readonly count: number - [k: string]: unknown - } - /** - * List of the periods of time the user had the view in foreground (focused in the browser) - */ - readonly in_foreground_periods?: { - /** - * Duration in ns between start of the view and start of foreground period - */ - readonly start: number - /** - * Duration in ns of the view foreground period - */ - readonly duration: number - [k: string]: unknown - }[] - /** - * Average memory used during the view lifetime (in bytes) - */ - readonly memory_average?: number - /** - * Peak memory used during the view lifetime (in bytes) - */ - readonly memory_max?: number - /** - * Total number of cpu ticks during the view’s lifetime - */ - readonly cpu_ticks_count?: number - /** - * Average number of cpu ticks per second during the view’s lifetime - */ - readonly cpu_ticks_per_second?: number - /** - * Average refresh rate during the view’s lifetime (in frames per second) - */ - readonly refresh_rate_average?: number - /** - * Minimum refresh rate during the view’s lifetime (in frames per second) - */ - readonly refresh_rate_min?: number - /** - * Rate of slow frames during the view’s lifetime (in milliseconds per second) - */ - readonly slow_frames_rate?: number - /** - * Rate of freezes during the view’s lifetime (in seconds per hour) - */ - readonly freeze_rate?: number - /** - * Time taken for Flutter 'build' methods. - */ - flutter_build_time?: RumPerfMetric - /** - * Time taken for Flutter to rasterize the view. - */ - flutter_raster_time?: RumPerfMetric - /** - * The JavaScript refresh rate for React Native - */ - js_refresh_rate?: RumPerfMetric - /** - * Performance data. (Web Vitals, etc.) - */ - performance?: ViewPerformanceData - /** - * Accessibility properties of the view - */ - accessibility?: ViewAccessibilityProperties - [k: string]: unknown - } - /** - * Session properties - */ - readonly session?: { - /** - * Whether this session is currently active. Set to false to manually stop a session - */ - readonly is_active?: boolean - /** - * Whether this session has been sampled for replay - */ - readonly sampled_for_replay?: boolean - [k: string]: unknown - } - /** - * Feature flags properties - */ - readonly feature_flags?: { - [k: string]: unknown - } - /** - * Privacy properties - */ - readonly privacy?: { - /** - * The replay privacy level - */ - readonly replay_level: 'allow' | 'mask' | 'mask-user-input' [k: string]: unknown } /** @@ -1335,32 +1042,27 @@ export type RumViewEvent = CommonProperties & profiling?: ProfilingInternalContextSchema [k: string]: unknown } + [k: string]: unknown + } +/** + * Schema of all properties of a View Update event + */ +export type RumViewUpdateEvent = ViewContainerSchema & + StreamSchema & + ViewProperties & + CommonProperties & { /** - * Display properties + * RUM event type */ - readonly display?: { + readonly type: 'view_update' + /** + * Internal properties + */ + readonly _dd?: { /** - * Scroll properties + * Version of the update of the view event */ - readonly scroll?: { - /** - * Distance between the top and the lowest point reached on this view (in pixels) - */ - readonly max_depth: number - /** - * Page scroll top (scrolled distance) when the maximum scroll depth was reached for this view (in pixels) - */ - readonly max_depth_scroll_top: number - /** - * Maximum page scroll height (total height) for this view (in pixels) - */ - readonly max_scroll_height: number - /** - * Duration between the view start and the time the max scroll height was reached for this view (in nanoseconds) - */ - readonly max_scroll_height_time: number - [k: string]: unknown - } + readonly document_version: number [k: string]: unknown } [k: string]: unknown @@ -1956,6 +1658,348 @@ export interface StreamSchema { } [k: string]: unknown } +/** + * Shared optional view-specific properties used by both view and view_update events + */ +export interface ViewProperties { + /** + * View properties + */ + readonly view?: { + /** + * Duration in ns to the view is considered loaded + */ + readonly loading_time?: number + /** + * Duration in ns from the moment the view was started until all the initial network requests settled + */ + readonly network_settled_time?: number + /** + * Duration in ns to from the last interaction on previous view to the moment the current view was displayed + */ + readonly interaction_to_next_view_time?: number + /** + * Type of the loading of the view + */ + readonly loading_type?: + | 'initial_load' + | 'route_change' + | 'activity_display' + | 'activity_redisplay' + | 'fragment_display' + | 'fragment_redisplay' + | 'view_controller_display' + | 'view_controller_redisplay' + /** + * Time spent on the view in ns + */ + readonly time_spent?: number + /** + * @deprecated + * Duration in ns to the first rendering (deprecated in favor of `view.performance.fcp.timestamp`) + */ + readonly first_contentful_paint?: number + /** + * @deprecated + * Duration in ns to the largest contentful paint (deprecated in favor of `view.performance.lcp.timestamp`) + */ + readonly largest_contentful_paint?: number + /** + * @deprecated + * CSS selector path of the largest contentful paint element (deprecated in favor of `view.performance.lcp.target_selector`) + */ + readonly largest_contentful_paint_target_selector?: string + /** + * @deprecated + * Duration in ns of the first input event delay (deprecated in favor of `view.performance.fid.duration`) + */ + readonly first_input_delay?: number + /** + * @deprecated + * Duration in ns to the first input (deprecated in favor of `view.performance.fid.timestamp`) + */ + readonly first_input_time?: number + /** + * @deprecated + * CSS selector path of the first input target element (deprecated in favor of `view.performance.fid.target_selector`) + */ + readonly first_input_target_selector?: string + /** + * @deprecated + * Longest duration in ns between an interaction and the next paint (deprecated in favor of `view.performance.inp.duration`) + */ + readonly interaction_to_next_paint?: number + /** + * @deprecated + * Duration in ns between start of the view and start of the INP (deprecated in favor of `view.performance.inp.timestamp`) + */ + readonly interaction_to_next_paint_time?: number + /** + * @deprecated + * CSS selector path of the interacted element corresponding to INP (deprecated in favor of `view.performance.inp.target_selector`) + */ + readonly interaction_to_next_paint_target_selector?: string + /** + * @deprecated + * Total layout shift score that occurred on the view (deprecated in favor of `view.performance.cls.score`) + */ + readonly cumulative_layout_shift?: number + /** + * @deprecated + * Duration in ns between start of the view and start of the largest layout shift contributing to CLS (deprecated in favor of `view.performance.cls.timestamp`) + */ + readonly cumulative_layout_shift_time?: number + /** + * @deprecated + * CSS selector path of the first element (in document order) of the largest layout shift contributing to CLS (deprecated in favor of `view.performance.cls.target_selector`) + */ + readonly cumulative_layout_shift_target_selector?: string + /** + * Duration in ns to the complete parsing and loading of the document and its sub resources + */ + readonly dom_complete?: number + /** + * Duration in ns to the complete parsing and loading of the document without its sub resources + */ + readonly dom_content_loaded?: number + /** + * Duration in ns to the end of the parsing of the document + */ + readonly dom_interactive?: number + /** + * Duration in ns to the end of the load event handler execution + */ + readonly load_event?: number + /** + * Duration in ns to the response start of the document request + */ + readonly first_byte?: number + /** + * User custom timings of the view. As timing name is used as facet path, it must contain only letters, digits, or the characters - _ . @ $ + */ + readonly custom_timings?: { + [k: string]: number + } + /** + * Whether the View corresponding to this event is considered active + */ + readonly is_active?: boolean + /** + * Whether the View had a low average refresh rate + */ + readonly is_slow_rendered?: boolean + /** + * Properties of the actions of the view + */ + readonly action?: { + /** + * Number of actions that occurred on the view + */ + readonly count: number + [k: string]: unknown + } + /** + * Properties of the errors of the view + */ + readonly error?: { + /** + * Number of errors that occurred on the view + */ + readonly count: number + [k: string]: unknown + } + /** + * Properties of the crashes of the view + */ + readonly crash?: { + /** + * Number of crashes that occurred on the view + */ + readonly count: number + [k: string]: unknown + } + /** + * Properties of the long tasks of the view + */ + readonly long_task?: { + /** + * Number of long tasks that occurred on the view + */ + readonly count: number + [k: string]: unknown + } + /** + * Properties of the frozen frames of the view + */ + readonly frozen_frame?: { + /** + * Number of frozen frames that occurred on the view + */ + readonly count: number + [k: string]: unknown + } + /** + * List of slow frames during the view's lifetime + */ + readonly slow_frames?: { + /** + * Duration in ns between start of the view and the start of the slow frame + */ + readonly start: number + /** + * Duration in ns of the slow frame + */ + readonly duration: number + [k: string]: unknown + }[] + /** + * Properties of the resources of the view + */ + readonly resource?: { + /** + * Number of resources that occurred on the view + */ + readonly count: number + [k: string]: unknown + } + /** + * Properties of the frustrations of the view + */ + readonly frustration?: { + /** + * Number of frustrations that occurred on the view + */ + readonly count?: number + [k: string]: unknown + } + /** + * List of the periods of time the user had the view in foreground (focused in the browser) + */ + readonly in_foreground_periods?: { + /** + * Duration in ns between start of the view and start of foreground period + */ + readonly start: number + /** + * Duration in ns of the view foreground period + */ + readonly duration: number + [k: string]: unknown + }[] + /** + * Average memory used during the view lifetime (in bytes) + */ + readonly memory_average?: number + /** + * Peak memory used during the view lifetime (in bytes) + */ + readonly memory_max?: number + /** + * Total number of cpu ticks during the view's lifetime + */ + readonly cpu_ticks_count?: number + /** + * Average number of cpu ticks per second during the view's lifetime + */ + readonly cpu_ticks_per_second?: number + /** + * Average refresh rate during the view's lifetime (in frames per second) + */ + readonly refresh_rate_average?: number + /** + * Minimum refresh rate during the view's lifetime (in frames per second) + */ + readonly refresh_rate_min?: number + /** + * Rate of slow frames during the view's lifetime (in milliseconds per second) + */ + readonly slow_frames_rate?: number + /** + * Rate of freezes during the view's lifetime (in seconds per hour) + */ + readonly freeze_rate?: number + /** + * Time taken for Flutter 'build' methods. + */ + flutter_build_time?: RumPerfMetric + /** + * Time taken for Flutter to rasterize the view. + */ + flutter_raster_time?: RumPerfMetric + /** + * The JavaScript refresh rate for React Native + */ + js_refresh_rate?: RumPerfMetric + /** + * Performance data. (Web Vitals, etc.) + */ + performance?: ViewPerformanceData + /** + * Accessibility properties of the view + */ + accessibility?: ViewAccessibilityProperties + [k: string]: unknown + } + /** + * Session properties + */ + readonly session?: { + /** + * Whether this session is currently active. Set to false to manually stop a session + */ + readonly is_active?: boolean + /** + * Whether this session has been sampled for replay + */ + readonly sampled_for_replay?: boolean + [k: string]: unknown + } + /** + * Feature flags properties + */ + readonly feature_flags?: { + [k: string]: unknown + } + /** + * Privacy properties + */ + readonly privacy?: { + /** + * The replay privacy level + */ + readonly replay_level: 'allow' | 'mask' | 'mask-user-input' + [k: string]: unknown + } + /** + * Display properties + */ + readonly display?: { + /** + * Scroll properties + */ + readonly scroll?: { + /** + * Distance between the top and the lowest point reached on this view (in pixels) + */ + readonly max_depth: number + /** + * Page scroll top (scrolled distance) when the maximum scroll depth was reached for this view (in pixels) + */ + readonly max_depth_scroll_top: number + /** + * Maximum page scroll height (total height) for this view (in pixels) + */ + readonly max_scroll_height: number + /** + * Duration between the view start and the time the max scroll height was reached for this view (in nanoseconds) + */ + readonly max_scroll_height_time: number + [k: string]: unknown + } + [k: string]: unknown + } + [k: string]: unknown +} /** * Schema of properties for a technical performance metric */ 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..8c94f72a84 --- /dev/null +++ b/packages/rum-core/src/transport/startRumBatch.spec.ts @@ -0,0 +1,218 @@ +import { ExperimentalFeature, addExperimentalFeatures, resetExperimentalFeatures } from '@datadog/browser-core' +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 10', () => { + expect(PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL).toBe(10) + }) +}) diff --git a/packages/rum-core/src/transport/startRumBatch.ts b/packages/rum-core/src/transport/startRumBatch.ts index d4e6b31920..c7568f476f 100644 --- a/packages/rum-core/src/transport/startRumBatch.ts +++ b/packages/rum-core/src/transport/startRumBatch.ts @@ -1,10 +1,121 @@ import type { Observable, RawError, PageMayExitEvent, Encoder } from '@datadog/browser-core' -import { createBatch, createFlushController, createHttpRequest, DeflateEncoderStreamId } from '@datadog/browser-core' +import { + 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 { RumEventType } from '../rawRumEvent.types' +import { diffMerge, isEqual } from '../domain/view/viewDiff' + +export const PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL = 10 + +// Top-level assembled fields that should be diffed with simple equality +const ASSEMBLED_TOP_LEVEL_FIELDS = [ + 'service', + 'version', + 'source', + 'ddtags', + 'context', + 'connectivity', + 'usr', + 'device', + 'privacy', +] as const + +export function computeAssembledViewDiff( + current: AssembledRumEvent, + last: AssembledRumEvent +): AssembledRumEvent | undefined { + const currentObj = current as unknown as Record + const lastObj = last as unknown as Record + + const result: Record = { + type: RumEventType.VIEW_UPDATE, + date: currentObj.date, + application: currentObj.application, + session: currentObj.session, + } + + let hasChanges = false + + // --- view.* diff (MERGE strategy, nested-aware) --- + const currentView = currentObj.view as Record + const lastView = lastObj.view as Record + // view.id and view.url are always required by the schema (_common-schema.json) for backend routing + const viewResult: Record = { id: currentView.id, url: currentView.url } + + const viewDiff = diffMerge(currentView, lastView, { replaceKeys: new Set(['custom_timings']) }) + if (viewDiff) { + delete viewDiff.id // already in required fields + delete viewDiff.url // already in required fields + Object.assign(viewResult, viewDiff) + if (Object.keys(viewDiff).length > 0) { + hasChanges = true + } + } + result.view = viewResult + + // --- _dd.* diff (MERGE strategy, page_states APPEND) --- + const currentDd = currentObj._dd as Record + const lastDd = lastObj._dd as Record + // _dd.document_version and _dd.format_version are always required by the schema for backend routing + const ddResult: Record = { + document_version: currentDd.document_version, + format_version: currentDd.format_version, + } + + const ddDiff = diffMerge(currentDd, lastDd, { appendKeys: new Set(['page_states']) }) + if (ddDiff) { + delete ddDiff.document_version // already in required fields + delete ddDiff.format_version // already in required fields + Object.assign(ddResult, ddDiff) + if (Object.keys(ddDiff).length > 0) { + hasChanges = true + } + } + result._dd = ddResult + + // --- display.* diff (MERGE strategy) --- + const currentDisplay = currentObj.display as Record | undefined + const lastDisplay = lastObj.display as Record | undefined + if (currentDisplay && lastDisplay) { + const displayDiff = diffMerge(currentDisplay, lastDisplay) + if (displayDiff && Object.keys(displayDiff).length > 0) { + result.display = displayDiff + hasChanges = true + } + } else if (currentDisplay && !lastDisplay) { + result.display = currentDisplay + hasChanges = true + } else if (!currentDisplay && lastDisplay) { + result.display = null // signal deletion + hasChanges = true + } + + // --- Top-level assembled fields (REPLACE strategy) --- + for (const key of ASSEMBLED_TOP_LEVEL_FIELDS) { + const currentVal = currentObj[key] + const lastVal = lastObj[key] + if (!isEqual(currentVal, lastVal)) { + result[key] = currentVal + hasChanges = true + } + } + + if (!hasChanges) { + return undefined + } + + return result as unknown as AssembledRumEvent +} export function startRumBatch( configuration: RumConfiguration, @@ -28,12 +139,67 @@ export function startRumBatch( }), }) + let lastSentView: AssembledRumEvent | undefined + let currentViewId: string | 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 !== currentViewId) { + currentViewId = viewId + lastSentView = serverRumEvent + viewUpdatesSinceCheckpoint = 0 + batch.upsert(serverRumEvent, viewId) + return + } + + // View ended (is_active: false) + if (!(serverRumEvent.view as any).is_active) { + currentViewId = undefined + lastSentView = undefined + viewUpdatesSinceCheckpoint = 0 + batch.upsert(serverRumEvent, viewId) + return + } + + // Checkpoint: every N intermediate updates, send a full view + 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 + if (!lastSentView) { + // Safety fallback (should not happen in practice) + lastSentView = serverRumEvent + batch.upsert(serverRumEvent, viewId) + return + } + + 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/rum-events-format b/rum-events-format index 5622f2a87d..813aee6a6a 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit 5622f2a87d3e0c1ff3048967df1d71b49c8efa7d +Subproject commit 813aee6a6ab8aac51faf9401201b405c859696fb diff --git a/sandbox/index.html b/sandbox/index.html index ad7204d0fb..2193f5289f 100644 --- a/sandbox/index.html +++ b/sandbox/index.html @@ -17,7 +17,7 @@ telemetrySampleRate: 100, telemetryConfigurationSampleRate: 100, telemetryUsageSampleRate: 100, - enableExperimentalFeatures: [], + enableExperimentalFeatures: ['partial_view_updates'], }) DD_LOGS.init({ clientToken: 'xxx', @@ -28,6 +28,10 @@ }) DD_FLAGGING.init() + + diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index d654fa063f..6a703f9cb8 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..4f2125a953 --- /dev/null +++ b/test/e2e/scenario/rum/partialViewUpdates.scenario.ts @@ -0,0 +1,207 @@ +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 10 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 (10) updates to trigger a checkpoint. + for (let i = 0; i < 12; i++) { + await page.evaluate((n) => { + window.DD_RUM!.setViewName(`step-${n}`) + }, i) + } + + 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) + }) +})