Skip to content

Commit b7bd5dd

Browse files
committed
👌 Address code review: declarative view diff, simplify isEqual, remove safety fallback
1 parent 5e79702 commit b7bd5dd

2 files changed

Lines changed: 66 additions & 127 deletions

File tree

‎packages/rum-core/src/domain/view/viewDiff.ts‎

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,14 @@
1+
import { isIndexableObject } from '@datadog/browser-core'
2+
13
/**
24
* Compare two values for deep equality
35
*/
46
export function isEqual(a: unknown, b: unknown): boolean {
5-
// Reference equality
67
if (a === b) {
78
return true
89
}
910

10-
// Handle null/undefined
11-
if (a === null || b === null || a === undefined || b === undefined) {
12-
return a === b
13-
}
14-
15-
// Type mismatch
16-
if (typeof a !== typeof b) {
17-
return false
18-
}
19-
20-
// Primitives
21-
if (typeof a !== 'object') {
11+
if (a === null || typeof a !== 'object' || b === null || typeof b !== 'object') {
2212
return a === b
2313
}
2414

@@ -30,12 +20,11 @@ export function isEqual(a: unknown, b: unknown): boolean {
3020
return a.every((val, idx) => isEqual(val, b[idx]))
3121
}
3222

33-
// One is array, other is not
3423
if (Array.isArray(a) || Array.isArray(b)) {
3524
return false
3625
}
3726

38-
// Objects
27+
// Plain objects
3928
const aObj = a as Record<string, unknown>
4029
const bObj = b as Record<string, unknown>
4130
const aKeys = Object.keys(aObj)
@@ -54,6 +43,7 @@ export function isEqual(a: unknown, b: unknown): boolean {
5443
export interface DiffMergeOptions {
5544
replaceKeys?: Set<string>
5645
appendKeys?: Set<string>
46+
ignoreKeys?: Set<string>
5747
}
5848

5949
/**
@@ -62,6 +52,7 @@ export interface DiffMergeOptions {
6252
*
6353
* Default strategy is REPLACE (isEqual check). Exceptions:
6454
* - Both values are plain objects and key is not in replaceKeys: recurse (MERGE)
55+
* Sub-paths (e.g. 'view.custom_timings') are propagated to the recursive call.
6556
* - Both values are arrays and key is in appendKeys: include only new trailing elements (APPEND)
6657
*/
6758
export function diffMerge(
@@ -72,22 +63,23 @@ export function diffMerge(
7263
const result: Record<string, unknown> = {}
7364
const replaceKeys = options?.replaceKeys ?? new Set<string>()
7465
const appendKeys = options?.appendKeys ?? new Set<string>()
66+
const ignoreKeys = options?.ignoreKeys ?? new Set<string>()
7567

7668
for (const key of Object.keys(current)) {
69+
if (ignoreKeys.has(key)) {
70+
continue
71+
}
72+
7773
const currentVal = current[key]
7874
const lastSentVal = lastSent[key]
7975

80-
if (
81-
!replaceKeys.has(key) &&
82-
currentVal !== null &&
83-
typeof currentVal === 'object' &&
84-
!Array.isArray(currentVal) &&
85-
lastSentVal !== null &&
86-
typeof lastSentVal === 'object' &&
87-
!Array.isArray(lastSentVal)
88-
) {
76+
if (!replaceKeys.has(key) && isIndexableObject(currentVal) && isIndexableObject(lastSentVal)) {
8977
// Both are plain objects and not marked for replace: recurse (MERGE)
90-
const nestedDiff = diffMerge(currentVal as Record<string, unknown>, lastSentVal as Record<string, unknown>)
78+
const nestedDiff = diffMerge(currentVal, lastSentVal, {
79+
replaceKeys: extractSubPaths(replaceKeys, key),
80+
appendKeys: extractSubPaths(appendKeys, key),
81+
ignoreKeys: extractSubPaths(ignoreKeys, key),
82+
})
9183
if (nestedDiff) {
9284
result[key] = nestedDiff
9385
}
@@ -111,3 +103,13 @@ export function diffMerge(
111103

112104
return Object.keys(result).length > 0 ? result : undefined
113105
}
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/transport/startRumBatch.ts‎

Lines changed: 39 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Observable, RawError, PageMayExitEvent, Encoder } from '@datadog/browser-core'
22
import {
3+
combine,
34
createBatch,
45
createFlushController,
56
createHttpRequest,
@@ -12,115 +13,58 @@ import type { RumConfiguration } from '../domain/configuration'
1213
import type { LifeCycle } from '../domain/lifeCycle'
1314
import { LifeCycleEventType } from '../domain/lifeCycle'
1415
import type { AssembledRumEvent } from '../rawRumEvent.types'
16+
import type { RumViewEvent } from '../rumEvent.types'
1517
import { RumEventType } from '../rawRumEvent.types'
16-
import { diffMerge, isEqual } from '../domain/view/viewDiff'
18+
import { diffMerge } from '../domain/view/viewDiff'
1719

1820
export const PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL = 100
1921

20-
// Top-level assembled fields that should be diffed with simple equality
21-
const ASSEMBLED_TOP_LEVEL_FIELDS = [
22-
'service',
23-
'version',
24-
'source',
25-
'ddtags',
26-
'context',
27-
'connectivity',
28-
'usr',
29-
'device',
30-
'privacy',
31-
] as const
32-
3322
export function computeAssembledViewDiff(
3423
current: AssembledRumEvent,
3524
last: AssembledRumEvent
3625
): AssembledRumEvent | undefined {
3726
const currentObj = current as unknown as Record<string, unknown>
3827
const lastObj = last as unknown as Record<string, unknown>
3928

40-
const result: Record<string, unknown> = {
41-
type: RumEventType.VIEW_UPDATE,
42-
date: currentObj.date,
43-
application: currentObj.application,
44-
session: currentObj.session,
45-
}
46-
47-
let hasChanges = false
29+
const diff = diffMerge(currentObj, lastObj, {
30+
// context, connectivity, usr, device, privacy are objects — use REPLACE to avoid partial updates
31+
replaceKeys: new Set(['view.custom_timings', 'context', 'connectivity', 'usr', 'device', 'privacy']),
32+
appendKeys: new Set(['_dd.page_states']),
33+
// Ignore always-required fields — they are added back via combine regardless of changes
34+
ignoreKeys: new Set([
35+
'date',
36+
'type',
37+
'application',
38+
'session',
39+
'view.id',
40+
'view.url',
41+
'_dd.document_version',
42+
'_dd.format_version',
43+
]),
44+
})
4845

49-
// --- view.* diff (MERGE strategy, nested-aware) ---
50-
const currentView = currentObj.view as Record<string, unknown>
51-
const lastView = lastObj.view as Record<string, unknown>
52-
// view.id and view.url are always required by the schema (_common-schema.json) for backend routing
53-
const viewResult: Record<string, unknown> = { id: currentView.id, url: currentView.url }
54-
55-
// Note: diffMerge emits null for keys deleted between events. In practice, view fields only
56-
// appear (e.g. first_byte, lcp, cls become available as data arrives) and never disappear
57-
// within the same view — so the null-for-deleted-keys path is unreachable for view data.
58-
const viewDiff = diffMerge(currentView, lastView, { replaceKeys: new Set(['custom_timings']) })
59-
if (viewDiff) {
60-
delete viewDiff.id // already in required fields
61-
delete viewDiff.url // already in required fields
62-
Object.assign(viewResult, viewDiff)
63-
if (Object.keys(viewDiff).length > 0) {
64-
hasChanges = true
65-
}
46+
if (!diff) {
47+
return undefined
6648
}
67-
result.view = viewResult
6849

69-
// --- _dd.* diff (MERGE strategy, page_states APPEND) ---
50+
const currentView = currentObj.view as Record<string, unknown>
7051
const currentDd = currentObj._dd as Record<string, unknown>
71-
const lastDd = lastObj._dd as Record<string, unknown>
72-
// _dd.document_version and _dd.format_version are always required by the schema for backend routing
73-
const ddResult: Record<string, unknown> = {
74-
document_version: currentDd.document_version,
75-
format_version: currentDd.format_version,
76-
}
7752

78-
const ddDiff = diffMerge(currentDd, lastDd, { appendKeys: new Set(['page_states']) })
79-
if (ddDiff) {
80-
delete ddDiff.document_version // already in required fields
81-
delete ddDiff.format_version // already in required fields
82-
Object.assign(ddResult, ddDiff)
83-
if (Object.keys(ddDiff).length > 0) {
84-
hasChanges = true
85-
}
86-
}
87-
result._dd = ddResult
88-
89-
// --- display.* diff (MERGE strategy) ---
90-
const currentDisplay = currentObj.display as Record<string, unknown> | undefined
91-
const lastDisplay = lastObj.display as Record<string, unknown> | undefined
92-
if (currentDisplay && lastDisplay) {
93-
const displayDiff = diffMerge(currentDisplay, lastDisplay)
94-
if (displayDiff && Object.keys(displayDiff).length > 0) {
95-
result.display = displayDiff
96-
hasChanges = true
97-
}
98-
} else if (currentDisplay && !lastDisplay) {
99-
result.display = currentDisplay
100-
hasChanges = true
101-
} else if (!currentDisplay && lastDisplay) {
102-
// In practice this branch is unreachable: display (scroll metrics) only appears
103-
// once scroll is tracked and never goes away within the same view. Kept as a
104-
// defensive fallback in case the invariant is violated in the future.
105-
result.display = null
106-
hasChanges = true
107-
}
108-
109-
// --- Top-level assembled fields (REPLACE strategy) ---
110-
for (const key of ASSEMBLED_TOP_LEVEL_FIELDS) {
111-
const currentVal = currentObj[key]
112-
const lastVal = lastObj[key]
113-
if (!isEqual(currentVal, lastVal)) {
114-
result[key] = currentVal
115-
hasChanges = true
116-
}
117-
}
118-
119-
if (!hasChanges) {
120-
return undefined
121-
}
122-
123-
return result as unknown as AssembledRumEvent
53+
// Merge always-required fields on top of the diff for backend routing
54+
return combine(diff, {
55+
type: RumEventType.VIEW_UPDATE,
56+
date: currentObj.date,
57+
application: currentObj.application,
58+
session: currentObj.session,
59+
view: {
60+
id: currentView.id,
61+
url: currentView.url,
62+
},
63+
_dd: {
64+
document_version: currentDd.document_version,
65+
format_version: currentDd.format_version,
66+
},
67+
}) as unknown as AssembledRumEvent
12468
}
12569

12670
export function startRumBatch(
@@ -172,7 +116,7 @@ export function startRumBatch(
172116
}
173117

174118
// View ended (is_active: false)
175-
if (!(serverRumEvent.view as any).is_active) {
119+
if (!(serverRumEvent as RumViewEvent).view.is_active) {
176120
lastSentView = undefined
177121
viewUpdatesSinceCheckpoint = 0
178122
batch.upsert(serverRumEvent, viewId)
@@ -195,14 +139,7 @@ export function startRumBatch(
195139
// They intentionally bypass RAW_RUM_EVENT_COLLECTED → assembly → RUM_EVENT_COLLECTED, which
196140
// means they skip beforeSend entirely. view_update is an internal bandwidth optimization —
197141
// not a customer-visible event type, and not modifiable via beforeSend.
198-
if (!lastSentView) {
199-
// Safety fallback (should not happen in practice)
200-
lastSentView = serverRumEvent
201-
batch.upsert(serverRumEvent, viewId)
202-
return
203-
}
204-
205-
const diff = computeAssembledViewDiff(serverRumEvent, lastSentView)
142+
const diff = computeAssembledViewDiff(serverRumEvent, lastSentView!)
206143
lastSentView = serverRumEvent
207144
if (diff) {
208145
sendToExtension('rum', diff)

0 commit comments

Comments
 (0)