Skip to content

Commit c60c158

Browse files
committed
⚗️ Implement partial view updates at transport layer
When partial_view_updates is enabled, startRumBatch intercepts assembled view events and sends view_update diffs instead of full views for intermediate updates. Key design: diff runs post-assembly so beforeSend always sees full view events (backward-compatible). view_update events intentionally bypass the assembly pipeline — they are a bandwidth optimization, not a customer-visible event type. - computeAssembledViewDiff: diffs two assembled view events, always including required routing fields (view.id, view.url, _dd.document_version, format_version) - Routing state machine: handles new view / view-end / checkpoint / diff cases - view-end events (is_active: false) always sent as full view - Full view checkpoint every 100 updates for backend recovery - Exclude view_update from trackEventCounts and assembly beforeSend guard - Add E2E tests covering all routing cases
1 parent 778507b commit c60c158

7 files changed

Lines changed: 644 additions & 23 deletions

File tree

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: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { ExperimentalFeature, addExperimentalFeatures, isExperimentalFeatureEnabled } from '@datadog/browser-core'
2+
import { resetExperimentalFeatures } from '@datadog/browser-core/src/tools/experimentalFeatures'
3+
import { registerCleanupTask } from '@datadog/browser-core/test'
4+
import type { AssembledRumEvent } from '../rawRumEvent.types'
5+
import { RumEventType } from '../rawRumEvent.types'
6+
import { computeAssembledViewDiff, PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL } from './startRumBatch'
7+
8+
function makeAssembledView(overrides: Record<string, unknown> = {}): AssembledRumEvent {
9+
return {
10+
type: RumEventType.VIEW,
11+
date: 1000,
12+
application: { id: 'app-1' },
13+
session: { id: 'sess-1', type: 'user' },
14+
view: {
15+
id: 'view-1',
16+
name: 'Home',
17+
url: '/home',
18+
referrer: '',
19+
is_active: true,
20+
action: { count: 0 },
21+
error: { count: 0 },
22+
long_task: { count: 0 },
23+
resource: { count: 0 },
24+
time_spent: 0,
25+
},
26+
_dd: {
27+
document_version: 1,
28+
format_version: 2,
29+
sdk_name: 'rum',
30+
configuration: { start_session_replay_recording_manually: false },
31+
},
32+
service: 'my-service',
33+
version: '1.0.0',
34+
ddtags: 'env:prod',
35+
source: 'browser',
36+
context: {},
37+
...overrides,
38+
} as unknown as AssembledRumEvent
39+
}
40+
41+
describe('computeAssembledViewDiff', () => {
42+
it('should return undefined when nothing has changed', () => {
43+
const last = makeAssembledView()
44+
const current = makeAssembledView({
45+
_dd: {
46+
document_version: 2,
47+
format_version: 2,
48+
sdk_name: 'rum',
49+
configuration: { start_session_replay_recording_manually: false },
50+
},
51+
})
52+
const result = computeAssembledViewDiff(current, last)
53+
54+
// Only document_version changed (always required, not a "meaningful change")
55+
// view.* unchanged → should return undefined
56+
expect(result).toBeUndefined()
57+
})
58+
59+
it('should always include required routing fields', () => {
60+
const last = makeAssembledView()
61+
const current = makeAssembledView({
62+
_dd: {
63+
document_version: 2,
64+
format_version: 2,
65+
sdk_name: 'rum',
66+
configuration: { start_session_replay_recording_manually: false },
67+
},
68+
view: {
69+
id: 'view-1',
70+
name: 'Home',
71+
url: '/home',
72+
referrer: '',
73+
is_active: true,
74+
action: { count: 1 },
75+
error: { count: 0 },
76+
long_task: { count: 0 },
77+
resource: { count: 0 },
78+
time_spent: 100,
79+
},
80+
})
81+
const result = computeAssembledViewDiff(current, last)!
82+
83+
expect(result.type).toBe(RumEventType.VIEW_UPDATE)
84+
expect((result as any).application).toEqual({ id: 'app-1' })
85+
expect((result as any).session).toEqual({ id: 'sess-1', type: 'user' })
86+
expect((result.view as any).id).toBe('view-1')
87+
expect((result.view as any).url).toBe('/home')
88+
expect((result._dd as any).document_version).toBe(2)
89+
expect((result._dd as any).format_version).toBe(2)
90+
})
91+
92+
it('should include only changed view.* fields', () => {
93+
const last = makeAssembledView()
94+
const current = makeAssembledView({
95+
_dd: {
96+
document_version: 2,
97+
format_version: 2,
98+
sdk_name: 'rum',
99+
configuration: { start_session_replay_recording_manually: false },
100+
},
101+
view: {
102+
id: 'view-1',
103+
name: 'Home',
104+
url: '/home',
105+
referrer: '',
106+
is_active: true,
107+
action: { count: 3 },
108+
error: { count: 0 },
109+
long_task: { count: 0 },
110+
resource: { count: 0 },
111+
time_spent: 5000,
112+
},
113+
})
114+
const result = computeAssembledViewDiff(current, last)!
115+
116+
expect((result.view as any).action).toEqual({ count: 3 }) // changed
117+
expect((result.view as any).time_spent).toBe(5000) // changed
118+
expect((result.view as any).error).toBeUndefined() // unchanged, stripped
119+
expect((result.view as any).name).toBeUndefined() // unchanged, stripped
120+
expect((result.view as any).url).toBe('/home') // required routing field, always present
121+
})
122+
123+
it('should strip unchanged top-level assembled fields', () => {
124+
const last = makeAssembledView({ service: 'svc', version: '1.0.0' })
125+
const current = makeAssembledView({
126+
_dd: {
127+
document_version: 2,
128+
format_version: 2,
129+
sdk_name: 'rum',
130+
configuration: { start_session_replay_recording_manually: false },
131+
},
132+
view: {
133+
id: 'view-1',
134+
name: 'Home',
135+
url: '/home',
136+
referrer: '',
137+
is_active: true,
138+
action: { count: 1 },
139+
error: { count: 0 },
140+
long_task: { count: 0 },
141+
resource: { count: 0 },
142+
time_spent: 100,
143+
},
144+
service: 'svc',
145+
version: '1.0.0',
146+
})
147+
const result = computeAssembledViewDiff(current, last)!
148+
149+
expect(result.service).toBeUndefined() // unchanged, stripped
150+
expect((result as any).version).toBeUndefined() // unchanged, stripped
151+
})
152+
153+
it('should keep top-level assembled fields that changed', () => {
154+
const last = makeAssembledView({ service: 'old-service' })
155+
const current = makeAssembledView({
156+
_dd: {
157+
document_version: 2,
158+
format_version: 2,
159+
sdk_name: 'rum',
160+
configuration: { start_session_replay_recording_manually: false },
161+
},
162+
view: {
163+
id: 'view-1',
164+
name: 'Home',
165+
url: '/home',
166+
referrer: '',
167+
is_active: true,
168+
action: { count: 1 },
169+
error: { count: 0 },
170+
long_task: { count: 0 },
171+
resource: { count: 0 },
172+
time_spent: 100,
173+
},
174+
service: 'new-service',
175+
})
176+
const result = computeAssembledViewDiff(current, last)!
177+
178+
expect(result.service).toBe('new-service')
179+
})
180+
181+
it('should not mutate the input events', () => {
182+
const last = makeAssembledView()
183+
const current = makeAssembledView({
184+
_dd: {
185+
document_version: 2,
186+
format_version: 2,
187+
sdk_name: 'rum',
188+
configuration: { start_session_replay_recording_manually: false },
189+
},
190+
view: {
191+
id: 'view-1',
192+
name: 'Home',
193+
url: '/home',
194+
referrer: '',
195+
is_active: true,
196+
action: { count: 1 },
197+
error: { count: 0 },
198+
long_task: { count: 0 },
199+
resource: { count: 0 },
200+
time_spent: 100,
201+
},
202+
})
203+
const currentService = current.service
204+
computeAssembledViewDiff(current, last)
205+
206+
expect(current.service).toBe(currentService)
207+
})
208+
})
209+
210+
describe('startRumBatch partial_view_updates routing', () => {
211+
beforeEach(() => {
212+
addExperimentalFeatures([ExperimentalFeature.PARTIAL_VIEW_UPDATES])
213+
registerCleanupTask(resetExperimentalFeatures)
214+
})
215+
216+
it('PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL should be 100', () => {
217+
expect(PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL).toBe(100)
218+
})
219+
220+
it('PARTIAL_VIEW_UPDATES_NO_CHECKPOINT flag should be defined', () => {
221+
expect(ExperimentalFeature.PARTIAL_VIEW_UPDATES_NO_CHECKPOINT).toBe('partial_view_updates_no_checkpoint')
222+
})
223+
224+
it('PARTIAL_VIEW_UPDATES_NO_CHECKPOINT flag should be disabled by default', () => {
225+
expect(isExperimentalFeatureEnabled(ExperimentalFeature.PARTIAL_VIEW_UPDATES_NO_CHECKPOINT)).toBe(false)
226+
})
227+
})

0 commit comments

Comments
 (0)