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)
+ })
+})