Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0c85d05
⚗️ Add partial view updates foundation types and diff engine
mormubis Feb 17, 2026
2597bb6
⚗️ Integrate partial view updates into event pipeline
mormubis Feb 18, 2026
7fe1601
✅ Add E2E tests for partial view updates
mormubis Feb 18, 2026
d879295
🔧 Add ts-expect-error guards to fail typecheck when view_update schem…
mormubis Feb 18, 2026
09857ed
🚨 Fix lint, format, and typecheck issues
mormubis Feb 18, 2026
1519b1d
🔧 Replace VIEW_UPDATE runtime check with @ts-expect-error guard
mormubis Feb 19, 2026
b9cee17
✅ Enable view_update schema validation and fix downstream type errors
mormubis Feb 20, 2026
621f4bf
✅ Update partial view updates tests to use ACTIVE_VIEW fixture for in…
mormubis Mar 3, 2026
b8e2dd8
🐛 Emit full VIEW event (not VIEW_UPDATE) when view ends (is_active: f…
mormubis Mar 3, 2026
708c3b3
⚗️ Add count-based full VIEW checkpoint every 10 view_updates for rec…
mormubis Mar 3, 2026
867b246
✅ Add and test stripViewUpdateFields utility for post-assembly VIEW_U…
mormubis Mar 3, 2026
fc64686
⚗️ Strip unchanged assembly fields from VIEW_UPDATE events in batch l…
mormubis Mar 3, 2026
d55267c
✅ Update E2E tests for view-end full VIEW and periodic checkpoint beh…
mormubis Mar 3, 2026
a41049c
Remove partial_view_updates diff logic from viewCollection
mormubis Mar 4, 2026
f40af93
✅ Remove partial_view_updates tests from viewCollection.spec.ts — beh…
mormubis Mar 4, 2026
0141d58
✅ Write failing tests for computeAssembledViewDiff (TDD: tests first)
mormubis Mar 4, 2026
b41b8b5
♻️ Move partial_view_updates diff logic to transport layer (computeAs…
mormubis Mar 4, 2026
9aa2be8
🔥 Remove dead code: computeViewDiff, createViewDiffTracker, stripView…
mormubis Mar 4, 2026
8569a7c
✅ Verify full suite green after partial_view_updates transport refactor
mormubis Mar 4, 2026
7004e20
🐛 Export resetExperimentalFeatures from @datadog/browser-core public …
mormubis Mar 6, 2026
bc86eff
✨ Expose view_update events to developer extension
mormubis Mar 6, 2026
8d8ca00
🐛 Fix URL construction crash in eventRow.tsx for relative URLs
mormubis Mar 6, 2026
d766923
🐛 Always include view.url and _dd.format_version in view_update diffs
mormubis Mar 6, 2026
bd28350
👷 Regenerate types from rum-events-format PR #355 schemas
mormubis Mar 6, 2026
82450f9
🚨 Fix lint errors in eventRow.tsx (unnecessary assertion, missing cur…
mormubis Mar 6, 2026
add782f
🚨 Fix unnecessary type assertions in actions.scenario.ts
mormubis Mar 6, 2026
d740f87
👷 Sync schemas to rum-events-format master (PR #355 merged)
mormubis Mar 6, 2026
12472eb
🎨 Fix Prettier formatting in viewCollection, viewDiff, startRumBatch
mormubis Mar 6, 2026
0585494
✅ Fix checkpoint E2E test: use setViewName instead of addAction
mormubis Mar 6, 2026
1950458
✅ Fix checkpoint E2E test: flush initial view before triggering updates
mormubis Mar 9, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
RumLongTaskEvent,
RumResourceEvent,
RumViewEvent,
RumViewUpdateEvent,
RumVitalEvent,
} from '../../../../../../packages/rum-core/src/rumEvent.types'
import type { SdkEvent } from '../../../sdkEvent'
Expand All @@ -31,6 +32,7 @@ const RUM_EVENT_TYPE_COLOR = {
error: 'red',
long_task: 'yellow',
view: 'blue',
view_update: 'blue',
resource: 'cyan',
telemetry: 'teal',
vital: 'orange',
Expand Down Expand Up @@ -287,6 +289,8 @@ export const EventDescription = React.memo(({ event }: { event: SdkEvent }) => {
switch (event.type) {
case 'view':
return <ViewDescription event={event} />
case 'view_update':
return <ViewUpdateDescription event={event} />
case 'long_task':
return <LongTaskDescription event={event} />
case 'error':
Expand Down Expand Up @@ -337,6 +341,34 @@ function ViewDescription({ event }: { event: RumViewEvent }) {
)
}

// view.id is the only field always present in a view_update diff (routing field)
const VIEW_UPDATE_REQUIRED_KEYS = new Set(['id'])

function ViewUpdateDescription({ event }: { event: RumViewUpdateEvent }) {
const changedFieldCount = event.view
? Object.keys(event.view).filter((k) => !VIEW_UPDATE_REQUIRED_KEYS.has(k)).length
: 0
const viewName = event.view ? event.view.name || event.view.url : undefined

return (
<>
View update <Emphasis>v{event._dd.document_version}</Emphasis>
{viewName && (
<>
{' '}
· <Emphasis>{viewName}</Emphasis>
</>
)}
{changedFieldCount > 0 && (
<>
{' '}
· <Emphasis>{changedFieldCount}</Emphasis> {changedFieldCount === 1 ? 'field' : 'fields'} changed
</>
)}
</>
)
}

function ActionDescription({ event }: { event: RumActionEvent }) {
const actionName = event.action.target?.name
const frustrationTypes = event.action.frustration?.type
Expand Down Expand Up @@ -424,5 +456,12 @@ function Emphasis({ children }: { children: ReactNode }) {
}

function getViewName(view: { name?: string; url: string }) {
return `${view.name || new URL(view.url).pathname}`
if (view.name) {
return view.name
}
try {
return new URL(view.url).pathname
} catch {
return view.url
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function startEventCollection(strategy: EventCollectionStrategy, onEvents
function compareEvents(a: SdkEvent, b: SdkEvent) {
// Sort events chronologically
if (a.date !== b.date) {
return b.date - a.date
return (b.date ?? 0) - (a.date ?? 0)
}

// If two events have the same date, make sure to display View events last. This ensures that View
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { TrackingConsent, createTrackingConsentState } from './domain/trackingCo
export {
isExperimentalFeatureEnabled,
addExperimentalFeatures,
resetExperimentalFeatures,
getExperimentalFeatures,
initFeatureFlags,
ExperimentalFeature,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tools/experimentalFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export enum ExperimentalFeature {
LCP_SUBPARTS = 'lcp_subparts',
INP_SUBPARTS = 'inp_subparts',
TOO_MANY_REQUESTS_INVESTIGATION = 'too_many_requests_investigation',
PARTIAL_VIEW_UPDATES = 'partial_view_updates',
}

const enabledExperimentalFeatures: Set<ExperimentalFeature> = new Set()
Expand Down
48 changes: 48 additions & 0 deletions packages/rum-core/src/domain/assembly.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,54 @@ describe('rum assembly', () => {
})
})

it('should not allow dismissing view_update events', () => {
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
partialConfiguration: {
beforeSend: () => false,
},
})

const displaySpy = spyOn(display, 'warn')
notifyRawRumEvent(lifeCycle, {
rawRumEvent: createRawRumEvent(RumEventType.VIEW_UPDATE),
})

expect(serverRumEvents.length).toBe(1)
expect(displaySpy).toHaveBeenCalledWith("Can't dismiss view events using beforeSend!")
})

it('should call beforeSend with view_update type and only the changed view fields', () => {
let capturedEvent: RumEvent | undefined
const { lifeCycle } = setupAssemblyTestWithDefaults({
partialConfiguration: {
beforeSend: (event) => {
capturedEvent = event
},
},
})

notifyRawRumEvent(lifeCycle, {
rawRumEvent: createRawRumEvent(RumEventType.VIEW_UPDATE, {
view: {
action: { count: 5 },
error: { count: 2 },
},
_dd: { document_version: 3 },
}),
})

// beforeSend receives view_update, not view — the event type changed with partial_view_updates
expect(capturedEvent!.type).toBe('view_update')
// Only the specific changed fields from the raw diff are present
expect((capturedEvent!.view as any).action?.count).toBe(5)
expect((capturedEvent!.view as any).error?.count).toBe(2)
// Fields not in the diff are absent (unlike a full view event which would have all fields)
expect((capturedEvent!.view as any).is_active).toBeUndefined()
expect((capturedEvent!.view as any).resource).toBeUndefined()
// Session context is still fully populated by the assembly pipeline
expect(capturedEvent!.session.id).toBe('1234')
})

it('should not dismiss when true is returned', () => {
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
partialConfiguration: {
Expand Down
9 changes: 8 additions & 1 deletion packages/rum-core/src/domain/assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export function startRumAssembly(
...VIEW_MODIFIABLE_FIELD_PATHS,
...ROOT_MODIFIABLE_FIELD_PATHS,
},
[RumEventType.VIEW_UPDATE]: {
'view.performance.lcp.resource_url': 'string',
...USER_CUSTOMIZABLE_FIELD_PATHS,
...VIEW_MODIFIABLE_FIELD_PATHS,
...ROOT_MODIFIABLE_FIELD_PATHS,
},
[RumEventType.ERROR]: {
'error.message': 'string',
'error.stack': 'string',
Expand Down Expand Up @@ -129,7 +135,8 @@ function shouldSend(
const result = limitModification(event, modifiableFieldPathsByEvent[event.type], (event) =>
beforeSend(event, domainContext)
)
if (result === false && event.type !== RumEventType.VIEW) {
const eventType = event.type as RumEventType
if (result === false && eventType !== RumEventType.VIEW && eventType !== RumEventType.VIEW_UPDATE) {
return false
}
if (result === false) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('featureFlagContexts', () => {
})

describe('assemble hook', () => {
it('should add feature flag evaluations on VIEW and ERROR by default ', () => {
it('should add feature flag evaluations on VIEW, VIEW_UPDATE, and ERROR by default ', () => {
lifeCycle.notify(LifeCycleEventType.BEFORE_VIEW_CREATED, {
startClocks: relativeToClocks(0 as RelativeTime),
} as ViewCreatedEvent)
Expand All @@ -39,6 +39,10 @@ describe('featureFlagContexts', () => {
eventType: 'view',
startTime: 0 as RelativeTime,
} as AssembleHookParams)
const defaultViewUpdateAttributes = hooks.triggerHook(HookNames.Assemble, {
eventType: 'view_update' as any,
startTime: 0 as RelativeTime,
} as AssembleHookParams)
const defaultErrorAttributes = hooks.triggerHook(HookNames.Assemble, {
eventType: 'error',
startTime: 0 as RelativeTime,
Expand All @@ -51,6 +55,14 @@ describe('featureFlagContexts', () => {
},
})

expect(defaultViewUpdateAttributes).toEqual(
jasmine.objectContaining({
feature_flags: {
feature: 'foo',
},
})
)

expect(defaultErrorAttributes).toEqual({
type: 'error',
feature_flags: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function startFeatureFlagContexts(
hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | SKIPPED => {
const trackFeatureFlagsForEvents = (configuration.trackFeatureFlagsForEvents as RumEventType[]).concat([
RumEventType.VIEW,
RumEventType.VIEW_UPDATE,
RumEventType.ERROR,
])
if (!trackFeatureFlagsForEvents.includes(eventType as RumEventType)) {
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()
})
})
})
Loading