Skip to content

Commit 4839750

Browse files
authored
⚗️ Partial view updates (experimental) (#4201)
1 parent 623d6ec commit 4839750

13 files changed

Lines changed: 781 additions & 21 deletions

File tree

packages/core/src/tools/experimentalFeatures.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export enum ExperimentalFeature {
1818
FEATURE_OPERATION_VITAL = 'feature_operation_vital',
1919
TOO_MANY_REQUESTS_INVESTIGATION = 'too_many_requests_investigation',
2020
SESSION_RENEWAL_DEBUG_CONTEXT = 'session_renewal_debug_context',
21+
PARTIAL_VIEW_UPDATES = 'partial_view_updates',
2122
}
2223

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

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

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -436,27 +436,29 @@ describe('rum assembly', () => {
436436
describe('service and version', () => {
437437
const extraConfigurationOptions = { service: 'default-service', version: 'default-version' }
438438

439-
Object.values(RumEventType).forEach((eventType) => {
440-
it(`should be modifiable for ${eventType}`, () => {
441-
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
442-
partialConfiguration: {
443-
...extraConfigurationOptions,
444-
beforeSend: (event) => {
445-
event.service = 'bar'
446-
event.version = '0.2.0'
439+
Object.values(RumEventType)
440+
.filter((eventType) => eventType !== RumEventType.VIEW_UPDATE)
441+
.forEach((eventType) => {
442+
it(`should be modifiable for ${eventType}`, () => {
443+
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
444+
partialConfiguration: {
445+
...extraConfigurationOptions,
446+
beforeSend: (event) => {
447+
event.service = 'bar'
448+
event.version = '0.2.0'
447449

448-
return true
450+
return true
451+
},
449452
},
450-
},
451-
})
453+
})
452454

453-
notifyRawRumEvent(lifeCycle, {
454-
rawRumEvent: createRawRumEvent(eventType),
455+
notifyRawRumEvent(lifeCycle, {
456+
rawRumEvent: createRawRumEvent(eventType),
457+
})
458+
expect((serverRumEvents[0] as RumResourceEvent).service).toBe('bar')
459+
expect((serverRumEvents[0] as RumResourceEvent).version).toBe('0.2.0')
455460
})
456-
expect((serverRumEvents[0] as RumResourceEvent).service).toBe('bar')
457-
expect((serverRumEvents[0] as RumResourceEvent).version).toBe('0.2.0')
458461
})
459-
})
460462

461463
it('should be added to the event as ddtags', () => {
462464
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 reach this pipeline.
53+
// The full view already went through assembly (as RumEventType.VIEW), so any beforeSend
54+
// modifications (e.g. PII scrubbing) are already reflected in the view_update diff.
55+
[RumEventType.VIEW_UPDATE]: {},
5256
[RumEventType.ERROR]: {
5357
'error.message': 'string',
5458
'error.stack': 'string',
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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 objects with same keys in different order', () => {
32+
expect(isEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true)
33+
})
34+
35+
it('should return true for equal arrays', () => {
36+
expect(isEqual([1, 2, 3], [1, 2, 3])).toBe(true)
37+
})
38+
39+
it('should return false for arrays with different lengths', () => {
40+
expect(isEqual([1, 2], [1, 2, 3])).toBe(false)
41+
})
42+
43+
it('should return false for arrays with different values', () => {
44+
expect(isEqual([1, 2, 3], [1, 2, 4])).toBe(false)
45+
})
46+
47+
it('should return false when comparing array to non-array', () => {
48+
expect(isEqual([1], { 0: 1 })).toBe(false)
49+
})
50+
51+
it('should return false for type mismatch', () => {
52+
expect(isEqual(1, '1')).toBe(false)
53+
})
54+
})
55+
56+
describe('diffMerge', () => {
57+
it('should return undefined when there are no changes', () => {
58+
const result = diffMerge({ a: 1, b: 'x' }, { a: 1, b: 'x' })
59+
expect(result).toBeUndefined()
60+
})
61+
62+
it('should return changed primitive fields', () => {
63+
const result = diffMerge({ a: 1, b: 2 }, { a: 1, b: 1 })
64+
expect(result).toEqual({ b: 2 })
65+
})
66+
67+
it('should include new fields not present in lastSent', () => {
68+
const result = diffMerge({ a: 1, b: 2 }, { a: 1 })
69+
expect(result).toEqual({ b: 2 })
70+
})
71+
72+
it('should set null for deleted keys', () => {
73+
const result = diffMerge({ a: 1 }, { a: 1, b: 2 })
74+
expect(result).toEqual({ b: null })
75+
})
76+
77+
it('should recursively diff nested objects', () => {
78+
const result = diffMerge({ nested: { x: 1, y: 2 } }, { nested: { x: 1, y: 1 } })
79+
expect(result).toEqual({ nested: { y: 2 } })
80+
})
81+
82+
it('should return undefined for unchanged nested objects', () => {
83+
const result = diffMerge({ nested: { x: 1 } }, { nested: { x: 1 } })
84+
expect(result).toBeUndefined()
85+
})
86+
87+
it('should include new nested objects', () => {
88+
const result = diffMerge({ nested: { x: 1 } }, {})
89+
expect(result).toEqual({ nested: { x: 1 } })
90+
})
91+
92+
describe('replaceKeys option', () => {
93+
it('should use full replace strategy for specified keys', () => {
94+
const result = diffMerge({ arr: [1, 2, 3] }, { arr: [1, 2] }, { replaceKeys: new Set(['arr']) })
95+
expect(result).toEqual({ arr: [1, 2, 3] })
96+
})
97+
98+
it('should not include replace key if unchanged', () => {
99+
const result = diffMerge({ arr: [1, 2] }, { arr: [1, 2] }, { replaceKeys: new Set(['arr']) })
100+
expect(result).toBeUndefined()
101+
})
102+
})
103+
104+
describe('appendKeys option', () => {
105+
it('should append only new trailing elements for array keys', () => {
106+
const result = diffMerge({ items: [1, 2, 3] }, { items: [1, 2] }, { appendKeys: new Set(['items']) })
107+
expect(result).toEqual({ items: [3] })
108+
})
109+
110+
it('should include full array when it first appears', () => {
111+
const result = diffMerge({ items: [1, 2] }, {}, { appendKeys: new Set(['items']) })
112+
expect(result).toEqual({ items: [1, 2] })
113+
})
114+
115+
it('should not include append key if array has not grown', () => {
116+
const result = diffMerge({ items: [1, 2] }, { items: [1, 2] }, { appendKeys: new Set(['items']) })
117+
expect(result).toBeUndefined()
118+
})
119+
})
120+
})
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { isIndexableObject } from '@datadog/browser-core'
2+
3+
/**
4+
* Compare two values for deep equality
5+
*/
6+
export function isEqual(a: unknown, b: unknown): boolean {
7+
if (a === b) {
8+
return true
9+
}
10+
11+
if (a === null || typeof a !== 'object' || b === null || typeof b !== 'object') {
12+
return a === b
13+
}
14+
15+
// Arrays
16+
if (Array.isArray(a) && Array.isArray(b)) {
17+
if (a.length !== b.length) {
18+
return false
19+
}
20+
return a.every((val, idx) => isEqual(val, b[idx]))
21+
}
22+
23+
if (Array.isArray(a) || Array.isArray(b)) {
24+
return false
25+
}
26+
27+
// Plain objects
28+
const aObj = a as Record<string, unknown>
29+
const bObj = b as Record<string, unknown>
30+
const aKeys = Object.keys(aObj)
31+
const bKeys = Object.keys(bObj)
32+
33+
if (aKeys.length !== bKeys.length) {
34+
return false
35+
}
36+
37+
return aKeys.every((key) => bKeys.includes(key) && isEqual(aObj[key], bObj[key]))
38+
}
39+
40+
/**
41+
* Options for controlling diff merge behavior
42+
*/
43+
export interface DiffMergeOptions {
44+
replaceKeys?: Set<string>
45+
appendKeys?: Set<string>
46+
ignoreKeys?: Set<string>
47+
}
48+
49+
/**
50+
* MERGE strategy: compare two objects and return an object with only changed fields.
51+
* Returns undefined if no changes.
52+
*
53+
* Default strategy is REPLACE (isEqual check). Exceptions:
54+
* - Both values are plain objects and key is not in replaceKeys: recurse (MERGE), sub-paths
55+
* (e.g. 'view.custom_timings') are propagated to the recursive call.
56+
* - Both values are arrays and key is in appendKeys: include only new trailing elements (APPEND)
57+
*/
58+
export function diffMerge(
59+
current: Record<string, unknown>,
60+
lastSent: Record<string, unknown>,
61+
options?: DiffMergeOptions
62+
): Record<string, unknown> | undefined {
63+
const result: Record<string, unknown> = {}
64+
const replaceKeys = options?.replaceKeys ?? new Set<string>()
65+
const appendKeys = options?.appendKeys ?? new Set<string>()
66+
const ignoreKeys = options?.ignoreKeys ?? new Set<string>()
67+
68+
for (const key of Object.keys(current)) {
69+
if (ignoreKeys.has(key)) {
70+
continue
71+
}
72+
73+
const currentVal = current[key]
74+
const lastSentVal = lastSent[key]
75+
76+
if (!replaceKeys.has(key) && isIndexableObject(currentVal) && isIndexableObject(lastSentVal)) {
77+
// Both are plain objects and not marked for replace: recurse (MERGE)
78+
const nestedDiff = diffMerge(currentVal, lastSentVal, {
79+
replaceKeys: extractSubPaths(replaceKeys, key),
80+
appendKeys: extractSubPaths(appendKeys, key),
81+
ignoreKeys: extractSubPaths(ignoreKeys, key),
82+
})
83+
if (nestedDiff) {
84+
result[key] = nestedDiff
85+
}
86+
} else if (appendKeys.has(key) && Array.isArray(currentVal) && Array.isArray(lastSentVal)) {
87+
// Array in appendKeys: include only new trailing elements (APPEND)
88+
if (currentVal.length > lastSentVal.length) {
89+
result[key] = currentVal.slice(lastSentVal.length)
90+
}
91+
} else if (!isEqual(currentVal, lastSentVal)) {
92+
// Default: replace the whole value (REPLACE)
93+
result[key] = currentVal
94+
}
95+
}
96+
97+
// Deleted keys: present in lastSent but not in current
98+
for (const key of Object.keys(lastSent)) {
99+
if (!(key in current)) {
100+
result[key] = null
101+
}
102+
}
103+
104+
return Object.keys(result).length > 0 ? result : undefined
105+
}
106+
107+
function extractSubPaths(keys: Set<string>, prefix: string): Set<string> {
108+
const result = new Set<string>()
109+
for (const key of keys) {
110+
if (key.startsWith(`${prefix}.`)) {
111+
result.add(key.slice(prefix.length + 1))
112+
}
113+
}
114+
return result
115+
}

packages/rum-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type {
77
CommonProperties,
88
RumErrorEvent,
99
RumViewEvent,
10+
RumViewUpdateEvent,
1011
RumResourceEvent,
1112
RumLongTaskEvent,
1213
RumVitalEvent,

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const RumEventType = {
2626
ERROR: 'error',
2727
LONG_TASK: 'long_task',
2828
VIEW: 'view',
29+
VIEW_UPDATE: 'view_update',
2930
RESOURCE: 'resource',
3031
VITAL: 'vital',
3132
} as const
@@ -180,6 +181,19 @@ export interface RawRumViewEvent {
180181
}
181182
}
182183

184+
export interface RawRumViewUpdateEvent {
185+
date: TimeStamp
186+
type: typeof RumEventType.VIEW_UPDATE
187+
view: Partial<RawRumViewEvent['view']>
188+
_dd: Partial<RawRumViewEvent['_dd']> & {
189+
document_version: number
190+
}
191+
display?: Partial<ViewDisplay>
192+
privacy?: RawRumViewEvent['privacy']
193+
device?: RawRumViewEvent['device']
194+
feature_flags?: Context
195+
}
196+
183197
interface ViewDisplay {
184198
scroll: {
185199
max_depth?: number
@@ -403,6 +417,7 @@ export type RawRumEvent =
403417
| RawRumErrorEvent
404418
| RawRumResourceEvent
405419
| RawRumViewEvent
420+
| RawRumViewUpdateEvent
406421
| RawRumLongTaskEvent
407422
| RawRumLongAnimationFrameEvent
408423
| RawRumActionEvent

0 commit comments

Comments
 (0)