Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/tools/experimentalFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export enum ExperimentalFeature {
TOO_MANY_REQUESTS_INVESTIGATION = 'too_many_requests_investigation',
COMPOSED_PATH_SELECTOR = 'composed_path_selector',
TRACK_RESOURCE_HEADERS = 'track_resource_headers',
PARTIAL_VIEW_UPDATES = 'partial_view_updates',
PARTIAL_VIEW_UPDATES_NO_CHECKPOINT = 'partial_view_updates_no_checkpoint',
}

const enabledExperimentalFeatures: Set<ExperimentalFeature> = new Set()
Expand Down
36 changes: 20 additions & 16 deletions packages/rum-core/src/domain/assembly.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,27 +388,31 @@ describe('rum assembly', () => {
describe('service and version', () => {
const extraConfigurationOptions = { service: 'default-service', version: 'default-version' }

Object.values(RumEventType).forEach((eventType) => {
it(`should be modifiable for ${eventType}`, () => {
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
partialConfiguration: {
...extraConfigurationOptions,
beforeSend: (event) => {
event.service = 'bar'
event.version = '0.2.0'
// view_update events bypass the assembly pipeline (created post-assembly in startRumBatch)
// and are intentionally not modifiable via beforeSend.
Object.values(RumEventType)
.filter((eventType) => eventType !== RumEventType.VIEW_UPDATE)
.forEach((eventType) => {
it(`should be modifiable for ${eventType}`, () => {
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
partialConfiguration: {
...extraConfigurationOptions,
beforeSend: (event) => {
event.service = 'bar'
event.version = '0.2.0'

return true
return true
},
},
},
})
})

notifyRawRumEvent(lifeCycle, {
rawRumEvent: createRawRumEvent(eventType),
notifyRawRumEvent(lifeCycle, {
rawRumEvent: createRawRumEvent(eventType),
})
expect((serverRumEvents[0] as RumResourceEvent).service).toBe('bar')
expect((serverRumEvents[0] as RumResourceEvent).version).toBe('0.2.0')
})
expect((serverRumEvents[0] as RumResourceEvent).service).toBe('bar')
expect((serverRumEvents[0] as RumResourceEvent).version).toBe('0.2.0')
})
})

it('should be added to the event as ddtags', () => {
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
Expand Down
4 changes: 4 additions & 0 deletions packages/rum-core/src/domain/assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export function startRumAssembly(
...VIEW_MODIFIABLE_FIELD_PATHS,
...ROOT_MODIFIABLE_FIELD_PATHS,
},
// view_update events are created post-assembly in startRumBatch.ts and never go through
// this pipeline — they intentionally bypass beforeSend. This entry is required by the
// exhaustive type but is never reached in practice.
[RumEventType.VIEW_UPDATE]: {},
[RumEventType.ERROR]: {
'error.message': 'string',
'error.stack': 'string',
Expand Down
2 changes: 1 addition & 1 deletion packages/rum-core/src/domain/trackEventCounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
116 changes: 116 additions & 0 deletions packages/rum-core/src/domain/view/viewDiff.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
})
115 changes: 115 additions & 0 deletions packages/rum-core/src/domain/view/viewDiff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { isIndexableObject } from '@datadog/browser-core'

/**
* Compare two values for deep equality
*/
export function isEqual(a: unknown, b: unknown): boolean {
if (a === b) {
return true
}

if (a === null || typeof a !== 'object' || b === null || typeof b !== '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]))
}

if (Array.isArray(a) || Array.isArray(b)) {
return false
}

// Plain objects
const aObj = a as Record<string, unknown>
const bObj = b as Record<string, unknown>
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<string>
appendKeys?: Set<string>
ignoreKeys?: Set<string>
}

/**
* MERGE strategy: compare two objects and return an object with only changed fields.
* Returns undefined if no changes.
*
* Default strategy is REPLACE (isEqual check). Exceptions:
* - Both values are plain objects and key is not in replaceKeys: recurse (MERGE), sub-paths
* (e.g. 'view.custom_timings') are propagated to the recursive call.
* - Both values are arrays and key is in appendKeys: include only new trailing elements (APPEND)
*/
export function diffMerge(
current: Record<string, unknown>,
lastSent: Record<string, unknown>,
options?: DiffMergeOptions
): Record<string, unknown> | undefined {
const result: Record<string, unknown> = {}
const replaceKeys = options?.replaceKeys ?? new Set<string>()
const appendKeys = options?.appendKeys ?? new Set<string>()
const ignoreKeys = options?.ignoreKeys ?? new Set<string>()

for (const key of Object.keys(current)) {
if (ignoreKeys.has(key)) {
continue
}

const currentVal = current[key]
const lastSentVal = lastSent[key]

if (!replaceKeys.has(key) && isIndexableObject(currentVal) && isIndexableObject(lastSentVal)) {
// Both are plain objects and not marked for replace: recurse (MERGE)
const nestedDiff = diffMerge(currentVal, lastSentVal, {
replaceKeys: extractSubPaths(replaceKeys, key),
appendKeys: extractSubPaths(appendKeys, key),
ignoreKeys: extractSubPaths(ignoreKeys, key),
})
if (nestedDiff) {
result[key] = nestedDiff
}
} else if (appendKeys.has(key) && Array.isArray(currentVal) && Array.isArray(lastSentVal)) {
// Array in appendKeys: include only new trailing elements (APPEND)
if (currentVal.length > lastSentVal.length) {
result[key] = currentVal.slice(lastSentVal.length)
}
} else if (!isEqual(currentVal, lastSentVal)) {
// Default: replace the whole value (REPLACE)
result[key] = currentVal
}
}

// 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
}

function extractSubPaths(keys: Set<string>, prefix: string): Set<string> {
const result = new Set<string>()
for (const key of keys) {
if (key.startsWith(`${prefix}.`)) {
result.add(key.slice(prefix.length + 1))
}
}
return result
}
17 changes: 17 additions & 0 deletions packages/rum-core/src/rawRumEvent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
RumLongTaskEvent,
RumResourceEvent,
RumViewEvent,
RumViewUpdateEvent,
RumVitalEvent,
} from './rumEvent.types'

Expand All @@ -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
Expand All @@ -34,6 +36,7 @@ export type RumEventType = (typeof RumEventType)[keyof typeof RumEventType]

export type AssembledRumEvent = (
| RumViewEvent
| RumViewUpdateEvent
| RumActionEvent
| RumResourceEvent
| RumErrorEvent
Expand Down Expand Up @@ -183,6 +186,19 @@ export interface RawRumViewEvent {
}
}

export interface RawRumViewUpdateEvent {
date: TimeStamp
type: typeof RumEventType.VIEW_UPDATE
view: Partial<RawRumViewEvent['view']>
_dd: Partial<RawRumViewEvent['_dd']> & {
document_version: number
}
display?: Partial<ViewDisplay>
privacy?: RawRumViewEvent['privacy']
device?: RawRumViewEvent['device']
feature_flags?: Context
}

interface ViewDisplay {
scroll: {
max_depth?: number
Expand Down Expand Up @@ -410,6 +426,7 @@ export type RawRumEvent =
| RawRumErrorEvent
| RawRumResourceEvent
| RawRumViewEvent
| RawRumViewUpdateEvent
| RawRumLongTaskEvent
| RawRumLongAnimationFrameEvent
| RawRumActionEvent
Expand Down
Loading
Loading