Skip to content

Commit cd062ff

Browse files
Integrate adlrb/partial-view (#4201) into staging-16
Integrated commit sha: 08a0506 Co-authored-by: mormubis <adrian.delarosa@datadoghq.com>
2 parents 1f1ed28 + 08a0506 commit cd062ff

12 files changed

Lines changed: 840 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
@@ -23,6 +23,8 @@ export enum ExperimentalFeature {
2323
USE_INCREMENTAL_CHANGE_RECORDS = 'use_incremental_change_records',
2424
TOO_MANY_REQUESTS_INVESTIGATION = 'too_many_requests_investigation',
2525
TRACK_RESOURCE_HEADERS = 'track_resource_headers',
26+
PARTIAL_VIEW_UPDATES = 'partial_view_updates',
27+
PARTIAL_VIEW_UPDATES_NO_CHECKPOINT = 'partial_view_updates_no_checkpoint',
2628
}
2729

2830
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
@@ -388,27 +388,31 @@ describe('rum assembly', () => {
388388
describe('service and version', () => {
389389
const extraConfigurationOptions = { service: 'default-service', version: 'default-version' }
390390

391-
Object.values(RumEventType).forEach((eventType) => {
392-
it(`should be modifiable for ${eventType}`, () => {
393-
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
394-
partialConfiguration: {
395-
...extraConfigurationOptions,
396-
beforeSend: (event) => {
397-
event.service = 'bar'
398-
event.version = '0.2.0'
391+
// view_update events bypass the assembly pipeline (created post-assembly in startRumBatch)
392+
// and are intentionally not modifiable via beforeSend.
393+
Object.values(RumEventType)
394+
.filter((eventType) => eventType !== RumEventType.VIEW_UPDATE)
395+
.forEach((eventType) => {
396+
it(`should be modifiable for ${eventType}`, () => {
397+
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
398+
partialConfiguration: {
399+
...extraConfigurationOptions,
400+
beforeSend: (event) => {
401+
event.service = 'bar'
402+
event.version = '0.2.0'
399403

400-
return true
404+
return true
405+
},
401406
},
402-
},
403-
})
407+
})
404408

405-
notifyRawRumEvent(lifeCycle, {
406-
rawRumEvent: createRawRumEvent(eventType),
409+
notifyRawRumEvent(lifeCycle, {
410+
rawRumEvent: createRawRumEvent(eventType),
411+
})
412+
expect((serverRumEvents[0] as RumResourceEvent).service).toBe('bar')
413+
expect((serverRumEvents[0] as RumResourceEvent).version).toBe('0.2.0')
407414
})
408-
expect((serverRumEvents[0] as RumResourceEvent).service).toBe('bar')
409-
expect((serverRumEvents[0] as RumResourceEvent).version).toBe('0.2.0')
410415
})
411-
})
412416

413417
it('should be added to the event as ddtags', () => {
414418
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: 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/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
@@ -183,6 +186,19 @@ export interface RawRumViewEvent {
183186
}
184187
}
185188

189+
export interface RawRumViewUpdateEvent {
190+
date: TimeStamp
191+
type: typeof RumEventType.VIEW_UPDATE
192+
view: Partial<RawRumViewEvent['view']>
193+
_dd: Partial<RawRumViewEvent['_dd']> & {
194+
document_version: number
195+
}
196+
display?: Partial<ViewDisplay>
197+
privacy?: RawRumViewEvent['privacy']
198+
device?: RawRumViewEvent['device']
199+
feature_flags?: Context
200+
}
201+
186202
interface ViewDisplay {
187203
scroll: {
188204
max_depth?: number
@@ -410,6 +426,7 @@ export type RawRumEvent =
410426
| RawRumErrorEvent
411427
| RawRumResourceEvent
412428
| RawRumViewEvent
429+
| RawRumViewUpdateEvent
413430
| RawRumLongTaskEvent
414431
| RawRumLongAnimationFrameEvent
415432
| RawRumActionEvent

0 commit comments

Comments
 (0)