Skip to content

Commit f3d0070

Browse files
Integrate adlrb/partial-view (#4201) into staging-12
Integrated commit sha: c60c158 Co-authored-by: mormubis <adrian.delarosa@datadoghq.com>
2 parents 14fd956 + c60c158 commit f3d0070

12 files changed

Lines changed: 936 additions & 23 deletions

File tree

packages/core/src/tools/experimentalFeatures.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export enum ExperimentalFeature {
2525
LCP_SUBPARTS = 'lcp_subparts',
2626
INP_SUBPARTS = 'inp_subparts',
2727
TOO_MANY_REQUESTS_INVESTIGATION = 'too_many_requests_investigation',
28+
PARTIAL_VIEW_UPDATES = 'partial_view_updates',
29+
PARTIAL_VIEW_UPDATES_NO_CHECKPOINT = 'partial_view_updates_no_checkpoint',
2830
}
2931

3032
const enabledExperimentalFeatures: Set<ExperimentalFeature> = new Set()

packages/rum-core/src/domain/assembly.spec.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -372,27 +372,31 @@ describe('rum assembly', () => {
372372
describe('service and version', () => {
373373
const extraConfigurationOptions = { service: 'default-service', version: 'default-version' }
374374

375-
Object.values(RumEventType).forEach((eventType) => {
376-
it(`should be modifiable for ${eventType}`, () => {
377-
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
378-
partialConfiguration: {
379-
...extraConfigurationOptions,
380-
beforeSend: (event) => {
381-
event.service = 'bar'
382-
event.version = '0.2.0'
375+
// view_update events bypass the assembly pipeline (created post-assembly in startRumBatch)
376+
// and are intentionally not modifiable via beforeSend.
377+
Object.values(RumEventType)
378+
.filter((eventType) => eventType !== RumEventType.VIEW_UPDATE)
379+
.forEach((eventType) => {
380+
it(`should be modifiable for ${eventType}`, () => {
381+
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
382+
partialConfiguration: {
383+
...extraConfigurationOptions,
384+
beforeSend: (event) => {
385+
event.service = 'bar'
386+
event.version = '0.2.0'
383387

384-
return true
388+
return true
389+
},
385390
},
386-
},
387-
})
391+
})
388392

389-
notifyRawRumEvent(lifeCycle, {
390-
rawRumEvent: createRawRumEvent(eventType),
393+
notifyRawRumEvent(lifeCycle, {
394+
rawRumEvent: createRawRumEvent(eventType),
395+
})
396+
expect((serverRumEvents[0] as RumResourceEvent).service).toBe('bar')
397+
expect((serverRumEvents[0] as RumResourceEvent).version).toBe('0.2.0')
391398
})
392-
expect((serverRumEvents[0] as RumResourceEvent).service).toBe('bar')
393-
expect((serverRumEvents[0] as RumResourceEvent).version).toBe('0.2.0')
394399
})
395-
})
396400

397401
it('should be added to the event as ddtags', () => {
398402
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({

packages/rum-core/src/domain/assembly.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export function startRumAssembly(
4949
...VIEW_MODIFIABLE_FIELD_PATHS,
5050
...ROOT_MODIFIABLE_FIELD_PATHS,
5151
},
52+
// view_update events are created post-assembly in startRumBatch.ts and never go through
53+
// this pipeline — they intentionally bypass beforeSend. This entry is required by the
54+
// exhaustive type but is never reached in practice.
55+
[RumEventType.VIEW_UPDATE]: {},
5256
[RumEventType.ERROR]: {
5357
'error.message': 'string',
5458
'error.stack': 'string',

packages/rum-core/src/domain/trackEventCounts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function trackEventCounts({
3030
}
3131

3232
const subscription = lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event): void => {
33-
if (event.type === 'view' || event.type === 'vital' || !isChildEvent(event)) {
33+
if (event.type === 'view' || event.type === 'view_update' || event.type === 'vital' || !isChildEvent(event)) {
3434
return
3535
}
3636
switch (event.type) {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { isEqual, diffMerge } from './viewDiff'
2+
3+
describe('isEqual', () => {
4+
it('should return true for identical primitives', () => {
5+
expect(isEqual(1, 1)).toBe(true)
6+
expect(isEqual('a', 'a')).toBe(true)
7+
expect(isEqual(true, true)).toBe(true)
8+
expect(isEqual(null, null)).toBe(true)
9+
expect(isEqual(undefined, undefined)).toBe(true)
10+
})
11+
12+
it('should return false for different primitives', () => {
13+
expect(isEqual(1, 2)).toBe(false)
14+
expect(isEqual('a', 'b')).toBe(false)
15+
expect(isEqual(true, false)).toBe(false)
16+
expect(isEqual(null, undefined)).toBe(false)
17+
})
18+
19+
it('should return true for deeply equal objects', () => {
20+
expect(isEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe(true)
21+
})
22+
23+
it('should return false for objects with different values', () => {
24+
expect(isEqual({ a: 1 }, { a: 2 })).toBe(false)
25+
})
26+
27+
it('should return false for objects with different keys', () => {
28+
expect(isEqual({ a: 1 }, { b: 1 })).toBe(false)
29+
})
30+
31+
it('should return true for equal arrays', () => {
32+
expect(isEqual([1, 2, 3], [1, 2, 3])).toBe(true)
33+
})
34+
35+
it('should return false for arrays with different lengths', () => {
36+
expect(isEqual([1, 2], [1, 2, 3])).toBe(false)
37+
})
38+
39+
it('should return false for arrays with different values', () => {
40+
expect(isEqual([1, 2, 3], [1, 2, 4])).toBe(false)
41+
})
42+
43+
it('should return false when comparing array to non-array', () => {
44+
expect(isEqual([1], { 0: 1 })).toBe(false)
45+
})
46+
47+
it('should return false for type mismatch', () => {
48+
expect(isEqual(1, '1')).toBe(false)
49+
})
50+
})
51+
52+
describe('diffMerge', () => {
53+
it('should return undefined when there are no changes', () => {
54+
const result = diffMerge({ a: 1, b: 'x' }, { a: 1, b: 'x' })
55+
expect(result).toBeUndefined()
56+
})
57+
58+
it('should return changed primitive fields', () => {
59+
const result = diffMerge({ a: 1, b: 2 }, { a: 1, b: 1 })
60+
expect(result).toEqual({ b: 2 })
61+
})
62+
63+
it('should include new fields not present in lastSent', () => {
64+
const result = diffMerge({ a: 1, b: 2 }, { a: 1 })
65+
expect(result).toEqual({ b: 2 })
66+
})
67+
68+
it('should set null for deleted keys', () => {
69+
const result = diffMerge({ a: 1 }, { a: 1, b: 2 })
70+
expect(result).toEqual({ b: null })
71+
})
72+
73+
it('should recursively diff nested objects', () => {
74+
const result = diffMerge({ nested: { x: 1, y: 2 } }, { nested: { x: 1, y: 1 } })
75+
expect(result).toEqual({ nested: { y: 2 } })
76+
})
77+
78+
it('should return undefined for unchanged nested objects', () => {
79+
const result = diffMerge({ nested: { x: 1 } }, { nested: { x: 1 } })
80+
expect(result).toBeUndefined()
81+
})
82+
83+
it('should include new nested objects', () => {
84+
const result = diffMerge({ nested: { x: 1 } }, {})
85+
expect(result).toEqual({ nested: { x: 1 } })
86+
})
87+
88+
describe('replaceKeys option', () => {
89+
it('should use full replace strategy for specified keys', () => {
90+
const result = diffMerge({ arr: [1, 2, 3] }, { arr: [1, 2] }, { replaceKeys: new Set(['arr']) })
91+
expect(result).toEqual({ arr: [1, 2, 3] })
92+
})
93+
94+
it('should not include replace key if unchanged', () => {
95+
const result = diffMerge({ arr: [1, 2] }, { arr: [1, 2] }, { replaceKeys: new Set(['arr']) })
96+
expect(result).toBeUndefined()
97+
})
98+
})
99+
100+
describe('appendKeys option', () => {
101+
it('should append only new trailing elements for array keys', () => {
102+
const result = diffMerge({ items: [1, 2, 3] }, { items: [1, 2] }, { appendKeys: new Set(['items']) })
103+
expect(result).toEqual({ items: [3] })
104+
})
105+
106+
it('should include full array when it first appears', () => {
107+
const result = diffMerge({ items: [1, 2] }, {}, { appendKeys: new Set(['items']) })
108+
expect(result).toEqual({ items: [1, 2] })
109+
})
110+
111+
it('should not include append key if array has not grown', () => {
112+
const result = diffMerge({ items: [1, 2] }, { items: [1, 2] }, { appendKeys: new Set(['items']) })
113+
expect(result).toBeUndefined()
114+
})
115+
})
116+
})
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { isEmptyObject } from '@datadog/browser-core'
2+
3+
/**
4+
* Compare two values for deep equality
5+
*/
6+
export function isEqual(a: unknown, b: unknown): boolean {
7+
// Reference equality
8+
if (a === b) {
9+
return true
10+
}
11+
12+
// Handle null/undefined
13+
if (a === null || b === null || a === undefined || b === undefined) {
14+
return a === b
15+
}
16+
17+
// Type mismatch
18+
if (typeof a !== typeof b) {
19+
return false
20+
}
21+
22+
// Primitives
23+
if (typeof a !== 'object') {
24+
return a === b
25+
}
26+
27+
// Arrays
28+
if (Array.isArray(a) && Array.isArray(b)) {
29+
if (a.length !== b.length) {
30+
return false
31+
}
32+
return a.every((val, idx) => isEqual(val, b[idx]))
33+
}
34+
35+
// One is array, other is not
36+
if (Array.isArray(a) || Array.isArray(b)) {
37+
return false
38+
}
39+
40+
// Objects
41+
const aObj = a as Record<string, unknown>
42+
const bObj = b as Record<string, unknown>
43+
const aKeys = Object.keys(aObj)
44+
const bKeys = Object.keys(bObj)
45+
46+
if (aKeys.length !== bKeys.length) {
47+
return false
48+
}
49+
50+
return aKeys.every((key) => bKeys.includes(key) && isEqual(aObj[key], bObj[key]))
51+
}
52+
53+
/**
54+
* Options for controlling diff merge behavior
55+
*/
56+
export interface DiffMergeOptions {
57+
replaceKeys?: Set<string>
58+
appendKeys?: Set<string>
59+
}
60+
61+
/**
62+
* MERGE strategy: compare two objects and return an object with only changed fields.
63+
* Returns undefined if no changes.
64+
*/
65+
export function diffMerge(
66+
current: Record<string, unknown>,
67+
lastSent: Record<string, unknown>,
68+
options?: DiffMergeOptions
69+
): Record<string, unknown> | undefined {
70+
const result: Record<string, unknown> = {}
71+
const replaceKeys = options?.replaceKeys || new Set<string>()
72+
const appendKeys = options?.appendKeys || new Set<string>()
73+
74+
// Check all keys in current
75+
for (const key of Object.keys(current)) {
76+
const currentVal = current[key]
77+
const lastSentVal = lastSent[key]
78+
79+
// REPLACE strategy for specific keys
80+
if (replaceKeys.has(key)) {
81+
if (!isEqual(currentVal, lastSentVal)) {
82+
result[key] = currentVal
83+
}
84+
continue
85+
}
86+
87+
// APPEND strategy for array keys
88+
if (appendKeys.has(key)) {
89+
if (Array.isArray(currentVal) && Array.isArray(lastSentVal)) {
90+
if (currentVal.length > lastSentVal.length) {
91+
// Include only new trailing elements
92+
result[key] = currentVal.slice(lastSentVal.length)
93+
}
94+
} else if (Array.isArray(currentVal) && !lastSentVal) {
95+
// Array appeared for the first time
96+
result[key] = currentVal
97+
}
98+
continue
99+
}
100+
101+
// Primitive comparison
102+
if (currentVal !== null && typeof currentVal !== 'object') {
103+
if (currentVal !== lastSentVal) {
104+
result[key] = currentVal
105+
}
106+
continue
107+
}
108+
109+
// Handle null explicitly
110+
if (currentVal === null) {
111+
if (currentVal !== lastSentVal) {
112+
result[key] = currentVal
113+
}
114+
continue
115+
}
116+
117+
// Array comparison (not in appendKeys)
118+
if (Array.isArray(currentVal)) {
119+
if (!isEqual(currentVal, lastSentVal)) {
120+
result[key] = currentVal
121+
}
122+
continue
123+
}
124+
125+
// Object comparison - recurse (no options propagation: replaceKeys/appendKeys apply only at top level)
126+
if (typeof currentVal === 'object' && lastSentVal && typeof lastSentVal === 'object') {
127+
const nestedDiff = diffMerge(currentVal as Record<string, unknown>, lastSentVal as Record<string, unknown>)
128+
if (nestedDiff && !isEmptyObject(nestedDiff)) {
129+
result[key] = nestedDiff
130+
}
131+
} else if (typeof currentVal === 'object' && !lastSentVal) {
132+
// New object appeared
133+
result[key] = currentVal
134+
}
135+
}
136+
137+
// Check for deleted keys (present in lastSent but not in current)
138+
for (const key of Object.keys(lastSent)) {
139+
if (!(key in current)) {
140+
result[key] = null
141+
}
142+
}
143+
144+
return Object.keys(result).length > 0 ? result : undefined
145+
}

packages/rum-core/src/rawRumEvent.types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
RumLongTaskEvent,
1919
RumResourceEvent,
2020
RumViewEvent,
21+
RumViewUpdateEvent,
2122
RumVitalEvent,
2223
} from './rumEvent.types'
2324

@@ -26,6 +27,7 @@ export const RumEventType = {
2627
ERROR: 'error',
2728
LONG_TASK: 'long_task',
2829
VIEW: 'view',
30+
VIEW_UPDATE: 'view_update',
2931
RESOURCE: 'resource',
3032
VITAL: 'vital',
3133
} as const
@@ -34,6 +36,7 @@ export type RumEventType = (typeof RumEventType)[keyof typeof RumEventType]
3436

3537
export type AssembledRumEvent = (
3638
| RumViewEvent
39+
| RumViewUpdateEvent
3740
| RumActionEvent
3841
| RumResourceEvent
3942
| RumErrorEvent
@@ -181,6 +184,19 @@ export interface RawRumViewEvent {
181184
}
182185
}
183186

187+
export interface RawRumViewUpdateEvent {
188+
date: TimeStamp
189+
type: typeof RumEventType.VIEW_UPDATE
190+
view: Partial<RawRumViewEvent['view']>
191+
_dd: Partial<RawRumViewEvent['_dd']> & {
192+
document_version: number
193+
}
194+
display?: Partial<ViewDisplay>
195+
privacy?: RawRumViewEvent['privacy']
196+
device?: RawRumViewEvent['device']
197+
feature_flags?: Context
198+
}
199+
184200
interface ViewDisplay {
185201
scroll: {
186202
max_depth?: number
@@ -407,6 +423,7 @@ export type RawRumEvent =
407423
| RawRumErrorEvent
408424
| RawRumResourceEvent
409425
| RawRumViewEvent
426+
| RawRumViewUpdateEvent
410427
| RawRumLongTaskEvent
411428
| RawRumLongAnimationFrameEvent
412429
| RawRumActionEvent

0 commit comments

Comments
 (0)