diff --git a/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx b/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx
index 106ce30f71..b96e1e72f6 100644
--- a/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx
+++ b/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx
@@ -31,6 +31,7 @@ const RUM_EVENT_TYPE_COLOR = {
error: 'red',
long_task: 'yellow',
view: 'blue',
+ view_update: 'blue',
resource: 'cyan',
telemetry: 'teal',
vital: 'orange',
@@ -109,7 +110,7 @@ export const EventRow = React.memo(
case 'date':
return (
|
- {formatDate(event.date)}
+ {formatDate(event.date!)}
|
)
case 'description':
diff --git a/developer-extension/src/panel/hooks/useEvents/eventCollection.ts b/developer-extension/src/panel/hooks/useEvents/eventCollection.ts
index cabe19636f..c8be0211e0 100644
--- a/developer-extension/src/panel/hooks/useEvents/eventCollection.ts
+++ b/developer-extension/src/panel/hooks/useEvents/eventCollection.ts
@@ -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
diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts
index 019df815e1..8bf771a76f 100644
--- a/packages/core/src/tools/experimentalFeatures.ts
+++ b/packages/core/src/tools/experimentalFeatures.ts
@@ -23,6 +23,7 @@ export enum ExperimentalFeature {
USE_CHANGE_RECORDS = 'use_change_records',
SOURCE_CODE_CONTEXT = 'source_code_context',
LCP_SUBPARTS = 'lcp_subparts',
+ PARTIAL_VIEW_UPDATES = 'partial_view_updates',
}
const enabledExperimentalFeatures: Set = new Set()
diff --git a/packages/rum-core/src/domain/assembly.spec.ts b/packages/rum-core/src/domain/assembly.spec.ts
index 56b94647a6..86376734c4 100644
--- a/packages/rum-core/src/domain/assembly.spec.ts
+++ b/packages/rum-core/src/domain/assembly.spec.ts
@@ -329,6 +329,22 @@ 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 not dismiss when true is returned', () => {
const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({
partialConfiguration: {
diff --git a/packages/rum-core/src/domain/assembly.ts b/packages/rum-core/src/domain/assembly.ts
index 2aacf816db..9a70eb47bb 100644
--- a/packages/rum-core/src/domain/assembly.ts
+++ b/packages/rum-core/src/domain/assembly.ts
@@ -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',
@@ -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) {
diff --git a/packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts b/packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts
index bca87d705f..eba9e54dde 100644
--- a/packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts
+++ b/packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts
@@ -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)
@@ -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,
@@ -51,6 +55,14 @@ describe('featureFlagContexts', () => {
},
})
+ expect(defaultViewUpdateAttributes).toEqual(
+ jasmine.objectContaining({
+ feature_flags: {
+ feature: 'foo',
+ },
+ })
+ )
+
expect(defaultErrorAttributes).toEqual({
type: 'error',
feature_flags: {
diff --git a/packages/rum-core/src/domain/contexts/featureFlagContext.ts b/packages/rum-core/src/domain/contexts/featureFlagContext.ts
index b2b1d92fad..9f7f47bd17 100644
--- a/packages/rum-core/src/domain/contexts/featureFlagContext.ts
+++ b/packages/rum-core/src/domain/contexts/featureFlagContext.ts
@@ -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)) {
diff --git a/packages/rum-core/src/domain/contexts/sourceCodeContext.ts b/packages/rum-core/src/domain/contexts/sourceCodeContext.ts
index de978d2213..ed335bd17c 100644
--- a/packages/rum-core/src/domain/contexts/sourceCodeContext.ts
+++ b/packages/rum-core/src/domain/contexts/sourceCodeContext.ts
@@ -54,6 +54,10 @@ export function startSourceCodeContext(hooks: Hooks) {
hooks.register(HookNames.Assemble, ({ domainContext, rawRumEvent }): DefaultRumEventAttributes | SKIPPED => {
buildContextByFile()
+ if (rawRumEvent.type === 'view_update') {
+ return SKIPPED
+ }
+
const url = getSourceUrl(domainContext, rawRumEvent)
if (url) {
diff --git a/packages/rum-core/src/domain/view/viewCollection.spec.ts b/packages/rum-core/src/domain/view/viewCollection.spec.ts
index 0678e8433e..3ccbb90a2a 100644
--- a/packages/rum-core/src/domain/view/viewCollection.spec.ts
+++ b/packages/rum-core/src/domain/view/viewCollection.spec.ts
@@ -1,4 +1,11 @@
-import { DISCARDED, HookNames, Observable } from '@datadog/browser-core'
+import {
+ DISCARDED,
+ HookNames,
+ Observable,
+ resetExperimentalFeatures,
+ addExperimentalFeatures,
+ ExperimentalFeature,
+} from '@datadog/browser-core'
import type { Duration, RelativeTime, ServerDuration, TimeStamp } from '@datadog/browser-core'
import { mockClock, registerCleanupTask } from '@datadog/browser-core/test'
import type { RecorderApi } from '../../boot/rumPublicApi'
@@ -305,3 +312,135 @@ describe('viewCollection', () => {
})
})
})
+
+describe('partial view updates', () => {
+ let lifeCycle: LifeCycle
+ let hooks: Hooks
+ let rawRumEvents: Array>
+
+ function setupViewCollection(partialConfiguration: Partial = {}) {
+ lifeCycle = new LifeCycle()
+ hooks = createHooks()
+ const viewHistory = mockViewHistory()
+ const domMutationObservable = new Observable()
+ const windowOpenObservable = new Observable()
+ const locationChangeObservable = new Observable()
+ mockClock()
+
+ const collectionResult = startViewCollection(
+ lifeCycle,
+ hooks,
+ mockRumConfiguration(partialConfiguration),
+ domMutationObservable,
+ windowOpenObservable,
+ locationChangeObservable,
+ noopRecorderApi,
+ viewHistory
+ )
+
+ rawRumEvents = collectAndValidateRawRumEvents(lifeCycle)
+
+ registerCleanupTask(() => {
+ collectionResult.stop()
+ viewHistory.stop()
+ })
+ return collectionResult
+ }
+
+ beforeEach(() => {
+ addExperimentalFeatures([ExperimentalFeature.PARTIAL_VIEW_UPDATES])
+ registerCleanupTask(resetExperimentalFeatures)
+ })
+
+ it('should emit full VIEW event on first update for a view', () => {
+ setupViewCollection()
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW)
+
+ expect(rawRumEvents.length).toBe(1)
+ expect(rawRumEvents[0].rawRumEvent.type).toBe(RumEventType.VIEW)
+ })
+
+ it('should emit VIEW_UPDATE on subsequent updates for the same view', () => {
+ setupViewCollection()
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW)
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, {
+ ...VIEW,
+ documentVersion: 4,
+ eventCounts: { ...VIEW.eventCounts, errorCount: 15 },
+ })
+
+ expect(rawRumEvents.length).toBe(2)
+ expect(rawRumEvents[0].rawRumEvent.type).toBe(RumEventType.VIEW)
+ expect(rawRumEvents[1].rawRumEvent.type).toBe(RumEventType.VIEW_UPDATE)
+ })
+
+ it('should include only changed fields in VIEW_UPDATE', () => {
+ setupViewCollection()
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW)
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, {
+ ...VIEW,
+ documentVersion: 4,
+ eventCounts: { ...VIEW.eventCounts, errorCount: 15 },
+ })
+
+ const updateEvent = rawRumEvents[1].rawRumEvent as any
+ expect(updateEvent.type).toBe(RumEventType.VIEW_UPDATE)
+ expect(updateEvent.view.error).toEqual({ count: 15 })
+ expect(updateEvent._dd.document_version).toBe(4)
+ // Unchanged fields should not be present
+ expect(updateEvent.view.action).toBeUndefined()
+ expect(updateEvent.view.resource).toBeUndefined()
+ })
+
+ it('should not emit event when no fields changed', () => {
+ setupViewCollection()
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW)
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW) // identical
+
+ expect(rawRumEvents.length).toBe(1) // only the initial VIEW
+ })
+
+ it('should emit full VIEW event when view.id changes', () => {
+ setupViewCollection()
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW)
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, {
+ ...VIEW,
+ id: 'new-view-id',
+ documentVersion: 1,
+ })
+
+ expect(rawRumEvents.length).toBe(2)
+ expect(rawRumEvents[0].rawRumEvent.type).toBe(RumEventType.VIEW)
+ expect(rawRumEvents[1].rawRumEvent.type).toBe(RumEventType.VIEW)
+ })
+
+ it('should emit only VIEW events when feature flag is OFF', () => {
+ resetExperimentalFeatures() // Ensure OFF
+ setupViewCollection()
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, VIEW)
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, {
+ ...VIEW,
+ documentVersion: 4,
+ eventCounts: { ...VIEW.eventCounts, errorCount: 15 },
+ })
+
+ expect(rawRumEvents.length).toBe(2)
+ expect(rawRumEvents[0].rawRumEvent.type).toBe(RumEventType.VIEW)
+ expect(rawRumEvents[1].rawRumEvent.type).toBe(RumEventType.VIEW)
+ })
+
+ it('should have monotonically increasing document_version across view and view_update', () => {
+ setupViewCollection()
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...VIEW, documentVersion: 1 })
+ lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, {
+ ...VIEW,
+ documentVersion: 2,
+ eventCounts: { ...VIEW.eventCounts, errorCount: 15 },
+ })
+
+ const firstEvent = rawRumEvents[0].rawRumEvent as RawRumViewEvent
+ const secondEvent = rawRumEvents[1].rawRumEvent as any
+ expect(firstEvent._dd.document_version).toBe(1)
+ expect(secondEvent._dd.document_version).toBe(2)
+ })
+})
diff --git a/packages/rum-core/src/domain/view/viewCollection.ts b/packages/rum-core/src/domain/view/viewCollection.ts
index 8a0119d9c0..84e11a5948 100644
--- a/packages/rum-core/src/domain/view/viewCollection.ts
+++ b/packages/rum-core/src/domain/view/viewCollection.ts
@@ -1,5 +1,14 @@
import type { Duration, ServerDuration, Observable } from '@datadog/browser-core'
-import { getTimeZone, DISCARDED, HookNames, isEmptyObject, mapValues, toServerDuration } from '@datadog/browser-core'
+import {
+ getTimeZone,
+ DISCARDED,
+ HookNames,
+ isEmptyObject,
+ mapValues,
+ toServerDuration,
+ isExperimentalFeatureEnabled,
+ ExperimentalFeature,
+} from '@datadog/browser-core'
import { discardNegativeDuration } from '../discardNegativeDuration'
import type { RecorderApi } from '../../boot/rumPublicApi'
import type { RawRumViewEvent, ViewPerformanceData } from '../../rawRumEvent.types'
@@ -15,6 +24,7 @@ import { trackViews } from './trackViews'
import type { ViewEvent, ViewOptions } from './trackViews'
import type { CommonViewMetrics } from './viewMetrics/trackCommonViewMetrics'
import type { InitialViewMetrics } from './viewMetrics/trackInitialViewMetrics'
+import { computeViewDiff, createViewDiffTracker } from './viewDiff'
export function startViewCollection(
lifeCycle: LifeCycle,
@@ -27,9 +37,57 @@ export function startViewCollection(
viewHistory: ViewHistory,
initialViewOptions?: ViewOptions
) {
- lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, (view) =>
- lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processViewUpdate(view, configuration, recorderApi))
- )
+ if (isExperimentalFeatureEnabled(ExperimentalFeature.PARTIAL_VIEW_UPDATES)) {
+ const diffTracker = createViewDiffTracker()
+ let currentViewId: string | undefined
+
+ lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, (view) => {
+ const rawEventData = processViewUpdate(view, configuration, recorderApi)
+
+ // New view: reset tracker, emit full view event
+ if (view.id !== currentViewId) {
+ currentViewId = view.id
+ diffTracker.reset()
+ diffTracker.recordSentState(rawEventData.rawRumEvent)
+ lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, rawEventData)
+ return
+ }
+
+ // Subsequent update: compute diff
+ const lastSent = diffTracker.getLastSentState()
+ if (!lastSent) {
+ // Safety: should not happen, but fall back to full view
+ diffTracker.recordSentState(rawEventData.rawRumEvent)
+ lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, rawEventData)
+ return
+ }
+
+ try {
+ const viewUpdateEvent = computeViewDiff(rawEventData.rawRumEvent, lastSent)
+
+ if (viewUpdateEvent) {
+ lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, {
+ rawRumEvent: viewUpdateEvent,
+ startTime: rawEventData.startTime,
+ duration: rawEventData.duration,
+ domainContext: rawEventData.domainContext,
+ })
+ }
+ // Empty diff: no event emitted (skip)
+ } catch {
+ // Diff computation failed: fall back to full view event
+ lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, rawEventData)
+ }
+
+ // Always update tracker with current state
+ diffTracker.recordSentState(rawEventData.rawRumEvent)
+ })
+ } else {
+ // Feature flag OFF: existing behavior unchanged
+ lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, (view) =>
+ lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processViewUpdate(view, configuration, recorderApi))
+ )
+ }
hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | DISCARDED => {
const view = viewHistory.findView(startTime)
diff --git a/packages/rum-core/src/domain/view/viewDiff.spec.ts b/packages/rum-core/src/domain/view/viewDiff.spec.ts
new file mode 100644
index 0000000000..3cd03b1b9f
--- /dev/null
+++ b/packages/rum-core/src/domain/view/viewDiff.spec.ts
@@ -0,0 +1,242 @@
+import type { ServerDuration, TimeStamp } from '@datadog/browser-core'
+import type { RawRumViewEvent } from '../../rawRumEvent.types'
+import { RumEventType, ViewLoadingType } from '../../rawRumEvent.types'
+import { computeViewDiff, createViewDiffTracker } from './viewDiff'
+
+function createBaseViewEvent(overrides?: Partial): RawRumViewEvent {
+ return {
+ date: 100 as TimeStamp,
+ type: RumEventType.VIEW,
+ view: {
+ loading_type: ViewLoadingType.INITIAL_LOAD,
+ time_spent: 1000 as ServerDuration,
+ is_active: true,
+ error: { count: 0 },
+ action: { count: 0 },
+ long_task: { count: 0 },
+ resource: { count: 0 },
+ frustration: { count: 0 },
+ },
+ _dd: {
+ document_version: 1,
+ configuration: {
+ start_session_replay_recording_manually: false,
+ },
+ },
+ ...overrides,
+ } as RawRumViewEvent
+}
+
+describe('computeViewDiff', () => {
+ it('should return undefined when states are identical', () => {
+ const state = createBaseViewEvent()
+ expect(computeViewDiff(state, state)).toBeUndefined()
+ })
+
+ it('should always include date, type, and _dd.document_version in output', () => {
+ const lastSent = createBaseViewEvent()
+ const current = createBaseViewEvent({
+ _dd: { ...lastSent._dd, document_version: 2 },
+ view: { ...lastSent.view, time_spent: 2000 as ServerDuration },
+ })
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff.type).toBe(RumEventType.VIEW_UPDATE)
+ expect(diff.date).toBe(current.date)
+ expect(diff._dd.document_version).toBe(2)
+ })
+
+ it('should include only changed primitive fields in view', () => {
+ const lastSent = createBaseViewEvent()
+ const current = createBaseViewEvent({
+ view: { ...lastSent.view, time_spent: 2000 as ServerDuration },
+ })
+ current._dd.document_version = 2
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff.view.time_spent as number).toBe(2000)
+ expect(diff.view.is_active).toBeUndefined() // unchanged, not in diff
+ expect(diff.view.error).toBeUndefined() // unchanged, not in diff
+ })
+
+ it('should include changed count objects', () => {
+ const lastSent = createBaseViewEvent()
+ const current = createBaseViewEvent({
+ view: { ...lastSent.view, error: { count: 3 } },
+ })
+ current._dd.document_version = 2
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff.view.error).toEqual({ count: 3 })
+ expect(diff.view.action).toBeUndefined() // unchanged
+ })
+
+ it('should include only changed sub-fields of nested objects', () => {
+ const lastSent = createBaseViewEvent()
+ lastSent.view.performance = { cls: { score: 0.1, timestamp: 100 as ServerDuration } }
+ const current = createBaseViewEvent()
+ current.view.performance = { cls: { score: 0.5, timestamp: 100 as ServerDuration } }
+ current._dd.document_version = 2
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff.view.performance).toBeDefined()
+ expect((diff.view.performance as any).cls.score).toBe(0.5)
+ })
+
+ it('should include new optional fields that appear', () => {
+ const lastSent = createBaseViewEvent()
+ const current = createBaseViewEvent({
+ view: { ...lastSent.view, largest_contentful_paint: 500 as ServerDuration },
+ })
+ current._dd.document_version = 2
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff.view.largest_contentful_paint as number).toBe(500)
+ })
+
+ it('should send null for optional fields that are removed', () => {
+ const lastSent = createBaseViewEvent({
+ view: {
+ ...createBaseViewEvent().view,
+ loading_time: 100 as ServerDuration,
+ },
+ })
+ const current = createBaseViewEvent()
+ current._dd.document_version = 2
+ // loading_time is NOT in current
+ const diff = computeViewDiff(current, lastSent)!
+ expect((diff.view as any).loading_time).toBeNull()
+ })
+
+ it('should include entire custom_timings when any timing changes (REPLACE)', () => {
+ const lastSent = createBaseViewEvent()
+ lastSent.view.custom_timings = { foo: 10 as ServerDuration }
+ const current = createBaseViewEvent()
+ current.view.custom_timings = { foo: 10 as ServerDuration, bar: 20 as ServerDuration }
+ current._dd.document_version = 2
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff.view.custom_timings).toEqual({ foo: 10 as any, bar: 20 as any })
+ })
+
+ it('should include full privacy object when changed (REPLACE)', () => {
+ const lastSent = createBaseViewEvent({ privacy: { replay_level: 'mask' as any } })
+ const current = createBaseViewEvent({ privacy: { replay_level: 'allow' as any } })
+ current._dd.document_version = 2
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff.privacy).toEqual({ replay_level: 'allow' })
+ })
+
+ it('should include full device object when changed (REPLACE)', () => {
+ const lastSent = createBaseViewEvent({ device: { locale: 'en-US' } })
+ const current = createBaseViewEvent({ device: { locale: 'fr-FR' } })
+ current._dd.document_version = 2
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff.device).toEqual({ locale: 'fr-FR' })
+ })
+
+ it('should include only new trailing elements for _dd.page_states (APPEND)', () => {
+ const lastSent = createBaseViewEvent()
+ lastSent._dd.page_states = [{ state: 'active' as any, start: 0 as any }]
+ const current = createBaseViewEvent()
+ current._dd.page_states = [
+ { state: 'active' as any, start: 0 as any },
+ { state: 'hidden' as any, start: 100 as any },
+ ]
+ current._dd.document_version = 2
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff._dd.page_states).toEqual([{ state: 'hidden' as any, start: 100 as any }])
+ })
+
+ it('should not include _dd.page_states when array has not grown', () => {
+ const lastSent = createBaseViewEvent()
+ lastSent._dd.page_states = [{ state: 'active' as any, start: 0 as any }]
+ const current = createBaseViewEvent()
+ current._dd.page_states = [{ state: 'active' as any, start: 0 as any }]
+ current._dd.document_version = 2
+ current.view.time_spent = 2000 as ServerDuration // trigger some change
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff._dd.page_states).toBeUndefined()
+ })
+
+ it('should handle multiple field types changing simultaneously', () => {
+ const lastSent = createBaseViewEvent()
+ const current = createBaseViewEvent({
+ view: { ...lastSent.view, time_spent: 5000 as ServerDuration, error: { count: 2 } },
+ })
+ current._dd = {
+ ...lastSent._dd,
+ document_version: 3,
+ replay_stats: { records_count: 10, segments_count: 1, segments_total_raw_size: 500 },
+ }
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff.view.time_spent as number).toBe(5000)
+ expect(diff.view.error).toEqual({ count: 2 })
+ expect(diff._dd.replay_stats).toBeDefined()
+ expect(diff._dd.document_version).toBe(3)
+ })
+
+ it('should not include _dd.configuration when unchanged', () => {
+ const lastSent = createBaseViewEvent()
+ const current = createBaseViewEvent({ view: { ...lastSent.view, time_spent: 2000 as ServerDuration } })
+ current._dd.document_version = 2
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff._dd.configuration).toBeUndefined()
+ })
+
+ it('should include only changed display.scroll fields (MERGE)', () => {
+ const lastSent = createBaseViewEvent({
+ display: { scroll: { max_depth: 100 } },
+ })
+ const current = createBaseViewEvent({
+ display: { scroll: { max_depth: 200 } },
+ })
+ current._dd.document_version = 2
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff.display).toBeDefined()
+ expect((diff.display as any).scroll.max_depth).toBe(200)
+ })
+
+ it('should include is_active change when view ends', () => {
+ const lastSent = createBaseViewEvent()
+ const current = createBaseViewEvent({
+ view: { ...lastSent.view, is_active: false, time_spent: 5000 as ServerDuration },
+ })
+ current._dd.document_version = 5
+ const diff = computeViewDiff(current, lastSent)!
+ expect(diff.view.is_active).toBe(false)
+ expect(diff.view.time_spent as number).toBe(5000)
+ })
+})
+
+describe('createViewDiffTracker', () => {
+ it('should store and return the last sent state', () => {
+ const tracker = createViewDiffTracker()
+ const state = createBaseViewEvent()
+ tracker.recordSentState(state)
+ expect(tracker.getLastSentState()).toEqual(state)
+ })
+
+ it('should return undefined when no state has been recorded', () => {
+ const tracker = createViewDiffTracker()
+ expect(tracker.getLastSentState()).toBeUndefined()
+ })
+
+ it('should return undefined after reset', () => {
+ const tracker = createViewDiffTracker()
+ tracker.recordSentState(createBaseViewEvent())
+ tracker.reset()
+ expect(tracker.getLastSentState()).toBeUndefined()
+ })
+
+ it('should deep clone the state so original mutations do not affect stored state', () => {
+ const tracker = createViewDiffTracker()
+ const state = createBaseViewEvent()
+ tracker.recordSentState(state)
+ state.view.error.count = 999
+ expect(tracker.getLastSentState()!.view.error.count).toBe(0)
+ })
+
+ it('should overwrite previously stored state', () => {
+ const tracker = createViewDiffTracker()
+ const state1 = createBaseViewEvent()
+ const state2 = createBaseViewEvent({ _dd: { ...state1._dd, document_version: 2 } })
+ tracker.recordSentState(state1)
+ tracker.recordSentState(state2)
+ expect(tracker.getLastSentState()!._dd.document_version).toBe(2)
+ })
+})
diff --git a/packages/rum-core/src/domain/view/viewDiff.ts b/packages/rum-core/src/domain/view/viewDiff.ts
new file mode 100644
index 0000000000..148486aaad
--- /dev/null
+++ b/packages/rum-core/src/domain/view/viewDiff.ts
@@ -0,0 +1,259 @@
+import { deepClone, isEmptyObject } from '@datadog/browser-core'
+import type { RawRumViewEvent, RawRumViewUpdateEvent } from '../../rawRumEvent.types'
+import { RumEventType } from '../../rawRumEvent.types'
+
+/**
+ * Compare two values for deep equality
+ */
+function isEqual(a: unknown, b: unknown): boolean {
+ // Reference equality
+ if (a === b) {
+ return true
+ }
+
+ // Handle null/undefined
+ if (a === null || b === null || a === undefined || b === undefined) {
+ return a === b
+ }
+
+ // Type mismatch
+ if (typeof a !== typeof b) {
+ return false
+ }
+
+ // Primitives
+ if (typeof a !== '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]))
+ }
+
+ // One is array, other is not
+ if (Array.isArray(a) || Array.isArray(b)) {
+ return false
+ }
+
+ // Objects
+ const aObj = a as Record
+ const bObj = b as Record
+ 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
+ */
+interface DiffMergeOptions {
+ replaceKeys?: Set
+ appendKeys?: Set
+}
+
+/**
+ * MERGE strategy: compare two objects and return an object with only changed fields.
+ * Returns undefined if no changes.
+ */
+function diffMerge(
+ current: Record,
+ lastSent: Record,
+ options?: DiffMergeOptions
+): Record | undefined {
+ const result: Record = {}
+ const replaceKeys = options?.replaceKeys || new Set()
+ const appendKeys = options?.appendKeys || new Set()
+
+ // Check all keys in current
+ for (const key of Object.keys(current)) {
+ const currentVal = current[key]
+ const lastSentVal = lastSent[key]
+
+ // REPLACE strategy for specific keys
+ if (replaceKeys.has(key)) {
+ if (!isEqual(currentVal, lastSentVal)) {
+ result[key] = currentVal
+ }
+ continue
+ }
+
+ // APPEND strategy for array keys
+ if (appendKeys.has(key)) {
+ if (Array.isArray(currentVal) && Array.isArray(lastSentVal)) {
+ if (currentVal.length > lastSentVal.length) {
+ // Include only new trailing elements
+ result[key] = currentVal.slice(lastSentVal.length)
+ }
+ } else if (Array.isArray(currentVal) && !lastSentVal) {
+ // Array appeared for the first time
+ result[key] = currentVal
+ }
+ continue
+ }
+
+ // Primitive comparison
+ if (currentVal !== null && typeof currentVal !== 'object') {
+ if (currentVal !== lastSentVal) {
+ result[key] = currentVal
+ }
+ continue
+ }
+
+ // Handle null explicitly
+ if (currentVal === null) {
+ if (currentVal !== lastSentVal) {
+ result[key] = currentVal
+ }
+ continue
+ }
+
+ // Array comparison (not in appendKeys)
+ if (Array.isArray(currentVal)) {
+ if (!isEqual(currentVal, lastSentVal)) {
+ result[key] = currentVal
+ }
+ continue
+ }
+
+ // Object comparison - recurse (no options propagation: replaceKeys/appendKeys apply only at top level)
+ if (typeof currentVal === 'object' && lastSentVal && typeof lastSentVal === 'object') {
+ const nestedDiff = diffMerge(currentVal as Record, lastSentVal as Record)
+ if (nestedDiff && !isEmptyObject(nestedDiff)) {
+ result[key] = nestedDiff
+ }
+ } else if (typeof currentVal === 'object' && !lastSentVal) {
+ // New object appeared
+ result[key] = currentVal
+ }
+ }
+
+ // Check for 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
+}
+
+/**
+ * Check if the diff has any meaningful changes beyond required fields
+ */
+function hasChanges(diff: RawRumViewUpdateEvent): boolean {
+ // Check if view has any keys
+ if (diff.view && Object.keys(diff.view).length > 0) {
+ return true
+ }
+
+ // Check if _dd has any keys besides document_version
+ if (diff._dd) {
+ const ddKeys = Object.keys(diff._dd).filter((k) => k !== 'document_version')
+ if (ddKeys.length > 0) {
+ return true
+ }
+ }
+
+ // Check if display, privacy, or device is defined
+ if (diff.display !== undefined || diff.privacy !== undefined || diff.device !== undefined) {
+ return true
+ }
+
+ return false
+}
+
+/**
+ * Compute a minimal diff between two RawRumViewEvent objects.
+ * Returns undefined if there are no meaningful changes.
+ */
+export function computeViewDiff(
+ current: RawRumViewEvent,
+ lastSent: RawRumViewEvent
+): RawRumViewUpdateEvent | undefined {
+ const diff: RawRumViewUpdateEvent = {
+ date: current.date,
+ type: RumEventType.VIEW_UPDATE,
+ view: {},
+ _dd: { document_version: current._dd.document_version },
+ }
+
+ // Diff view.* (MERGE, with custom_timings as REPLACE)
+ const viewDiff = diffMerge(current.view as Record, lastSent.view as Record, {
+ replaceKeys: new Set(['custom_timings']),
+ })
+ if (viewDiff) {
+ Object.assign(diff.view, viewDiff)
+ }
+
+ // Diff _dd.* (MERGE, with page_states as APPEND)
+ const ddDiff = diffMerge(current._dd as Record, lastSent._dd as Record, {
+ appendKeys: new Set(['page_states']),
+ })
+ if (ddDiff) {
+ // Remove document_version from ddDiff (already in required fields)
+ delete ddDiff.document_version
+ Object.assign(diff._dd, ddDiff)
+ }
+
+ // Diff display (MERGE, optional top-level)
+ if (current.display && lastSent.display) {
+ const displayDiff = diffMerge(
+ current.display as unknown as Record,
+ lastSent.display as unknown as Record
+ )
+ if (displayDiff) {
+ diff.display = displayDiff as RawRumViewUpdateEvent['display']
+ }
+ } else if (current.display && !lastSent.display) {
+ diff.display = current.display
+ } else if (!current.display && lastSent.display) {
+ // DELETE: field was present, now absent
+ diff.display = null as unknown as undefined
+ }
+
+ // Diff privacy (REPLACE, optional top-level)
+ if (!isEqual(current.privacy, lastSent.privacy)) {
+ diff.privacy = current.privacy ?? (null as unknown as undefined)
+ }
+
+ // Diff device (REPLACE, optional top-level)
+ if (!isEqual(current.device, lastSent.device)) {
+ diff.device = current.device ?? (null as unknown as undefined)
+ }
+
+ if (!hasChanges(diff)) {
+ return undefined
+ }
+
+ return diff
+}
+
+/**
+ * Create a tracker for managing last-sent view state
+ */
+export function createViewDiffTracker() {
+ let lastSentState: RawRumViewEvent | undefined
+
+ return {
+ recordSentState(state: RawRumViewEvent) {
+ lastSentState = deepClone(state)
+ },
+
+ getLastSentState(): RawRumViewEvent | undefined {
+ return lastSentState
+ },
+
+ reset() {
+ lastSentState = undefined
+ },
+ }
+}
diff --git a/packages/rum-core/src/domainContext.types.ts b/packages/rum-core/src/domainContext.types.ts
index 35e891bc5c..e82039c85a 100644
--- a/packages/rum-core/src/domainContext.types.ts
+++ b/packages/rum-core/src/domainContext.types.ts
@@ -6,17 +6,19 @@ import type { RumEventType } from './rawRumEvent.types'
export type RumEventDomainContext = T extends typeof RumEventType.VIEW
? RumViewEventDomainContext
- : T extends typeof RumEventType.ACTION
- ? RumActionEventDomainContext
- : T extends typeof RumEventType.RESOURCE
- ? RumFetchResourceEventDomainContext | RumXhrResourceEventDomainContext | RumOtherResourceEventDomainContext
- : T extends typeof RumEventType.ERROR
- ? RumErrorEventDomainContext
- : T extends typeof RumEventType.LONG_TASK
- ? RumLongTaskEventDomainContext
- : T extends typeof RumEventType.VITAL
- ? RumVitalEventDomainContext
- : never
+ : T extends typeof RumEventType.VIEW_UPDATE
+ ? RumViewEventDomainContext
+ : T extends typeof RumEventType.ACTION
+ ? RumActionEventDomainContext
+ : T extends typeof RumEventType.RESOURCE
+ ? RumFetchResourceEventDomainContext | RumXhrResourceEventDomainContext | RumOtherResourceEventDomainContext
+ : T extends typeof RumEventType.ERROR
+ ? RumErrorEventDomainContext
+ : T extends typeof RumEventType.LONG_TASK
+ ? RumLongTaskEventDomainContext
+ : T extends typeof RumEventType.VITAL
+ ? RumVitalEventDomainContext
+ : never
export interface RumViewEventDomainContext {
location: Readonly
diff --git a/packages/rum-core/src/rawRumEvent.types.ts b/packages/rum-core/src/rawRumEvent.types.ts
index de4a594878..cec020a169 100644
--- a/packages/rum-core/src/rawRumEvent.types.ts
+++ b/packages/rum-core/src/rawRumEvent.types.ts
@@ -26,6 +26,7 @@ export const RumEventType = {
ERROR: 'error',
LONG_TASK: 'long_task',
VIEW: 'view',
+ VIEW_UPDATE: 'view_update',
RESOURCE: 'resource',
VITAL: 'vital',
} as const
@@ -170,6 +171,19 @@ export interface RawRumViewEvent {
}
}
+export interface RawRumViewUpdateEvent {
+ date: TimeStamp
+ type: typeof RumEventType.VIEW_UPDATE
+ view: Partial
+ _dd: Partial & {
+ document_version: number
+ }
+ display?: Partial
+ privacy?: RawRumViewEvent['privacy']
+ device?: RawRumViewEvent['device']
+ feature_flags?: Context
+}
+
interface ViewDisplay {
scroll: {
max_depth?: number
@@ -391,6 +405,7 @@ export type RawRumEvent =
| RawRumErrorEvent
| RawRumResourceEvent
| RawRumViewEvent
+ | RawRumViewUpdateEvent
| RawRumLongTaskEvent
| RawRumLongAnimationFrameEvent
| RawRumActionEvent
diff --git a/packages/rum-core/src/rumEvent.types.ts b/packages/rum-core/src/rumEvent.types.ts
index 8a7f4fe953..dfb9045906 100644
--- a/packages/rum-core/src/rumEvent.types.ts
+++ b/packages/rum-core/src/rumEvent.types.ts
@@ -13,6 +13,7 @@ export type RumEvent =
| RumLongTaskEvent
| RumResourceEvent
| RumViewEvent
+ | RumViewUpdateEvent
| RumVitalEvent
/**
* Schema of all properties of an Action event
@@ -676,15 +677,15 @@ export type RumResourceEvent = CommonProperties &
*/
readonly size?: number
/**
- * Size in octet of the resource before removing any applied content encodings
+ * Size in octet of the response body before removing any applied content encodings
*/
readonly encoded_body_size?: number
/**
- * Size in octet of the resource after removing any applied encoding
+ * Size in octet of the response body after removing any applied encoding
*/
readonly decoded_body_size?: number
/**
- * Size in octet of the fetched resource
+ * Size in octet of the fetched response resource
*/
readonly transfer_size?: number
/**
@@ -829,6 +830,38 @@ export type RumResourceEvent = CommonProperties &
| 'video'
[k: string]: unknown
}
+ /**
+ * Request properties
+ */
+ readonly request?: {
+ /**
+ * Size in octet of the request body sent over the network (after encoding)
+ */
+ readonly encoded_body_size?: number
+ /**
+ * Size in octet of the request body before any encoding
+ */
+ readonly decoded_body_size?: number
+ /**
+ * HTTP headers of the resource request
+ */
+ readonly headers?: {
+ [k: string]: string
+ }
+ [k: string]: unknown
+ }
+ /**
+ * Response properties
+ */
+ readonly response?: {
+ /**
+ * HTTP headers of the resource response
+ */
+ readonly headers?: {
+ [k: string]: string
+ }
+ [k: string]: unknown
+ }
/**
* GraphQL requests parameters
*/
@@ -922,411 +955,404 @@ export type RumResourceEvent = CommonProperties &
*/
export type RumViewEvent = CommonProperties &
ViewContainerSchema &
- StreamSchema & {
+ StreamSchema &
+ ViewProperties & {
/**
* RUM event type
*/
readonly type: 'view'
/**
- * View properties
+ * Internal properties
*/
- readonly view: {
- /**
- * Duration in ns to the view is considered loaded
- */
- readonly loading_time?: number
- /**
- * Duration in ns from the moment the view was started until all the initial network requests settled
- */
- readonly network_settled_time?: number
- /**
- * Duration in ns to from the last interaction on previous view to the moment the current view was displayed
- */
- readonly interaction_to_next_view_time?: number
+ readonly _dd: {
/**
- * Type of the loading of the view
+ * Version of the update of the view event
*/
- readonly loading_type?:
- | 'initial_load'
- | 'route_change'
- | 'activity_display'
- | 'activity_redisplay'
- | 'fragment_display'
- | 'fragment_redisplay'
- | 'view_controller_display'
- | 'view_controller_redisplay'
+ readonly document_version: number
+ [k: string]: unknown
+ }
+ [k: string]: unknown
+ }
+/**
+ * Schema of all properties of a View Update event
+ */
+export type RumViewUpdateEvent = ViewContainerSchema &
+ StreamSchema &
+ ViewProperties & {
+ /**
+ * RUM event type
+ */
+ readonly type: 'view_update'
+ /**
+ * Start of the event in ms from epoch
+ */
+ readonly date?: number
+ /**
+ * Application properties
+ */
+ readonly application: {
/**
- * Time spent on the view in ns
+ * UUID of the application
*/
- readonly time_spent: number
+ readonly id: string
/**
- * @deprecated
- * Duration in ns to the first rendering (deprecated in favor of `view.performance.fcp.timestamp`)
+ * The user's current locale as a language tag (language + region), computed from their preferences and the app's supported languages, e.g. 'es-FR'.
*/
- readonly first_contentful_paint?: number
+ readonly current_locale?: string
+ [k: string]: unknown
+ }
+ /**
+ * Session properties
+ */
+ readonly session: {
/**
- * @deprecated
- * Duration in ns to the largest contentful paint (deprecated in favor of `view.performance.lcp.timestamp`)
+ * UUID of the session
*/
- readonly largest_contentful_paint?: number
+ readonly id: string
/**
- * @deprecated
- * CSS selector path of the largest contentful paint element (deprecated in favor of `view.performance.lcp.target_selector`)
+ * Type of the session
*/
- readonly largest_contentful_paint_target_selector?: string
+ readonly type: 'user' | 'synthetics' | 'ci_test'
/**
- * @deprecated
- * Duration in ns of the first input event delay (deprecated in favor of `view.performance.fid.duration`)
+ * Whether this session has a replay
*/
- readonly first_input_delay?: number
+ readonly has_replay?: boolean
+ [k: string]: unknown
+ }
+ /**
+ * The source of this event
+ */
+ readonly source?:
+ | 'android'
+ | 'ios'
+ | 'browser'
+ | 'flutter'
+ | 'react-native'
+ | 'roku'
+ | 'unity'
+ | 'kotlin-multiplatform'
+ | 'electron'
+ /**
+ * The service name for this application
+ */
+ service?: string
+ /**
+ * The version for this application
+ */
+ version?: string
+ /**
+ * The build version for this application
+ */
+ readonly build_version?: string
+ /**
+ * Generated unique ID of the application build. Unlike version or build_version this field is not meant to be coming from the user, but rather generated by the tooling for each build.
+ */
+ readonly build_id?: string
+ /**
+ * Tags of the event in key:value format, separated by commas (e.g. 'env:prod,version:1.2.3')
+ */
+ readonly ddtags?: string
+ /**
+ * View properties
+ */
+ readonly view: {
/**
- * @deprecated
- * Duration in ns to the first input (deprecated in favor of `view.performance.fid.timestamp`)
+ * UUID of the view
*/
- readonly first_input_time?: number
+ readonly id: string
/**
- * @deprecated
- * CSS selector path of the first input target element (deprecated in favor of `view.performance.fid.target_selector`)
+ * URL of the view
*/
- readonly first_input_target_selector?: string
+ url: string
/**
- * @deprecated
- * Longest duration in ns between an interaction and the next paint (deprecated in favor of `view.performance.inp.duration`)
+ * URL that linked to the initial view of the page
*/
- readonly interaction_to_next_paint?: number
+ referrer?: string
/**
- * @deprecated
- * Duration in ns between start of the view and start of the INP (deprecated in favor of `view.performance.inp.timestamp`)
+ * User defined name of the view
*/
- readonly interaction_to_next_paint_time?: number
+ name?: string
+ [k: string]: unknown
+ }
+ /**
+ * Internal properties
+ */
+ readonly _dd: {
/**
- * @deprecated
- * CSS selector path of the interacted element corresponding to INP (deprecated in favor of `view.performance.inp.target_selector`)
+ * Version of the update of the view event
*/
- readonly interaction_to_next_paint_target_selector?: string
+ readonly document_version: number
/**
- * @deprecated
- * Total layout shift score that occurred on the view (deprecated in favor of `view.performance.cls.score`)
+ * Version of the RUM event format
*/
- readonly cumulative_layout_shift?: number
+ readonly format_version?: 2
/**
- * @deprecated
- * Duration in ns between start of the view and start of the largest layout shift contributing to CLS (deprecated in favor of `view.performance.cls.timestamp`)
+ * Session-related internal properties
*/
- readonly cumulative_layout_shift_time?: number
+ session?: {
+ /**
+ * Session plan: 1 is the plan without replay, 2 is the plan with replay (deprecated)
+ */
+ plan?: 1 | 2
+ /**
+ * The precondition that led to the creation of the session
+ */
+ readonly session_precondition?:
+ | 'user_app_launch'
+ | 'inactivity_timeout'
+ | 'max_duration'
+ | 'background_launch'
+ | 'prewarm'
+ | 'from_non_interactive_session'
+ | 'explicit_stop'
+ [k: string]: unknown
+ }
/**
- * @deprecated
- * CSS selector path of the first element (in document order) of the largest layout shift contributing to CLS (deprecated in favor of `view.performance.cls.target_selector`)
+ * Subset of the SDK configuration options in use during its execution
*/
- readonly cumulative_layout_shift_target_selector?: string
+ readonly configuration?: {
+ /**
+ * The percentage of sessions tracked
+ */
+ readonly session_sample_rate?: number
+ /**
+ * The percentage of sessions with RUM & Session Replay pricing tracked
+ */
+ readonly session_replay_sample_rate?: number
+ /**
+ * The percentage of sessions profiled
+ */
+ readonly profiling_sample_rate?: number
+ /**
+ * The percentage of sessions with traced resources
+ */
+ readonly trace_sample_rate?: number
+ [k: string]: unknown
+ }
/**
- * Duration in ns to the complete parsing and loading of the document and its sub resources
+ * Browser SDK version
*/
- readonly dom_complete?: number
+ readonly browser_sdk_version?: string
/**
- * Duration in ns to the complete parsing and loading of the document without its sub resources
+ * SDK name (e.g. 'logs', 'rum', 'rum-slim', etc.)
*/
- readonly dom_content_loaded?: number
+ readonly sdk_name?: string
+ [k: string]: unknown
+ }
+ /**
+ * User properties
+ */
+ readonly usr?: {
/**
- * Duration in ns to the end of the parsing of the document
+ * Identifier of the user
*/
- readonly dom_interactive?: number
+ readonly id?: string
/**
- * Duration in ns to the end of the load event handler execution
+ * Name of the user
*/
- readonly load_event?: number
+ readonly name?: string
/**
- * Duration in ns to the response start of the document request
+ * Email of the user
*/
- readonly first_byte?: number
+ readonly email?: string
/**
- * User custom timings of the view. As timing name is used as facet path, it must contain only letters, digits, or the characters - _ . @ $
+ * Identifier of the user across sessions
*/
- readonly custom_timings?: {
- [k: string]: number
- }
+ readonly anonymous_id?: string
+ [k: string]: unknown
+ }
+ /**
+ * Account properties
+ */
+ readonly account?: {
/**
- * Whether the View corresponding to this event is considered active
+ * Identifier of the account
*/
- readonly is_active?: boolean
+ readonly id: string
/**
- * Whether the View had a low average refresh rate
+ * Name of the account
*/
- readonly is_slow_rendered?: boolean
+ readonly name?: string
+ [k: string]: unknown
+ }
+ /**
+ * Device connectivity properties
+ */
+ connectivity?: {
/**
- * Properties of the actions of the view
+ * Status of the device connectivity
*/
- readonly action: {
- /**
- * Number of actions that occurred on the view
- */
- readonly count: number
- [k: string]: unknown
- }
+ readonly status: 'connected' | 'not_connected' | 'maybe'
/**
- * Properties of the errors of the view
+ * The list of available network interfaces
*/
- readonly error: {
- /**
- * Number of errors that occurred on the view
- */
- readonly count: number
- [k: string]: unknown
- }
+ readonly interfaces?: (
+ | 'bluetooth'
+ | 'cellular'
+ | 'ethernet'
+ | 'wifi'
+ | 'wimax'
+ | 'mixed'
+ | 'other'
+ | 'unknown'
+ | 'none'
+ )[]
/**
- * Properties of the crashes of the view
+ * Cellular connection type reflecting the measured network performance
*/
- readonly crash?: {
- /**
- * Number of crashes that occurred on the view
- */
- readonly count: number
- [k: string]: unknown
- }
+ readonly effective_type?: 'slow-2g' | '2g' | '3g' | '4g'
/**
- * Properties of the long tasks of the view
+ * Cellular connectivity properties
*/
- readonly long_task?: {
+ readonly cellular?: {
/**
- * Number of long tasks that occurred on the view
+ * The type of a radio technology used for cellular connection
*/
- readonly count: number
- [k: string]: unknown
- }
- /**
- * Properties of the frozen frames of the view
- */
- readonly frozen_frame?: {
+ readonly technology?: string
/**
- * Number of frozen frames that occurred on the view
+ * The name of the SIM carrier
*/
- readonly count: number
+ readonly carrier_name?: string
[k: string]: unknown
}
+ [k: string]: unknown
+ }
+ /**
+ * Display properties
+ */
+ display?: {
/**
- * List of slow frames during the view’s lifetime
- */
- readonly slow_frames?: {
- /**
- * Duration in ns between start of the view and the start of the slow frame
- */
- readonly start: number
- /**
- * Duration in ns of the slow frame
- */
- readonly duration: number
- [k: string]: unknown
- }[]
- /**
- * Properties of the resources of the view
+ * The viewport represents the rectangular area that is currently being viewed. Content outside the viewport is not visible onscreen until scrolled into view.
*/
- readonly resource: {
+ readonly viewport?: {
/**
- * Number of resources that occurred on the view
+ * Width of the viewport (in pixels)
*/
- readonly count: number
- [k: string]: unknown
- }
- /**
- * Properties of the frustrations of the view
- */
- readonly frustration?: {
+ readonly width: number
/**
- * Number of frustrations that occurred on the view
+ * Height of the viewport (in pixels)
*/
- readonly count: number
+ readonly height: number
[k: string]: unknown
}
+ [k: string]: unknown
+ }
+ /**
+ * Synthetics properties
+ */
+ readonly synthetics?: {
/**
- * List of the periods of time the user had the view in foreground (focused in the browser)
- */
- readonly in_foreground_periods?: {
- /**
- * Duration in ns between start of the view and start of foreground period
- */
- readonly start: number
- /**
- * Duration in ns of the view foreground period
- */
- readonly duration: number
- [k: string]: unknown
- }[]
- /**
- * Average memory used during the view lifetime (in bytes)
+ * The identifier of the current Synthetics test
*/
- readonly memory_average?: number
+ readonly test_id: string
/**
- * Peak memory used during the view lifetime (in bytes)
+ * The identifier of the current Synthetics test results
*/
- readonly memory_max?: number
+ readonly result_id: string
/**
- * Total number of cpu ticks during the view’s lifetime
+ * Whether the event comes from a SDK instance injected by Synthetics
*/
- readonly cpu_ticks_count?: number
+ readonly injected?: boolean
+ [k: string]: unknown
+ }
+ /**
+ * CI Visibility properties
+ */
+ readonly ci_test?: {
/**
- * Average number of cpu ticks per second during the view’s lifetime
+ * The identifier of the current CI Visibility test execution
*/
- readonly cpu_ticks_per_second?: number
+ readonly test_execution_id: string
+ [k: string]: unknown
+ }
+ /**
+ * Operating system properties
+ */
+ os?: {
/**
- * Average refresh rate during the view’s lifetime (in frames per second)
+ * Operating system name, e.g. Android, iOS
*/
- readonly refresh_rate_average?: number
+ readonly name: string
/**
- * Minimum refresh rate during the view’s lifetime (in frames per second)
+ * Full operating system version, e.g. 8.1.1
*/
- readonly refresh_rate_min?: number
+ readonly version: string
/**
- * Rate of slow frames during the view’s lifetime (in milliseconds per second)
+ * Operating system build number, e.g. 15D21
*/
- readonly slow_frames_rate?: number
+ readonly build?: string
/**
- * Rate of freezes during the view’s lifetime (in seconds per hour)
+ * Major operating system version, e.g. 8
*/
- readonly freeze_rate?: number
+ readonly version_major: string
+ [k: string]: unknown
+ }
+ /**
+ * Device properties
+ */
+ device?: {
/**
- * Time taken for Flutter 'build' methods.
+ * Device type info
*/
- flutter_build_time?: RumPerfMetric
+ readonly type?: 'mobile' | 'desktop' | 'tablet' | 'tv' | 'gaming_console' | 'bot' | 'other'
/**
- * Time taken for Flutter to rasterize the view.
+ * Device marketing name, e.g. Xiaomi Redmi Note 8 Pro, Pixel 5, etc.
*/
- flutter_raster_time?: RumPerfMetric
+ readonly name?: string
/**
- * The JavaScript refresh rate for React Native
+ * Device SKU model, e.g. Samsung SM-988GN, etc. Quite often name and model can be the same.
*/
- js_refresh_rate?: RumPerfMetric
+ readonly model?: string
/**
- * Performance data. (Web Vitals, etc.)
+ * Device marketing brand, e.g. Apple, OPPO, Xiaomi, etc.
*/
- performance?: ViewPerformanceData
+ readonly brand?: string
/**
- * Accessibility properties of the view
+ * The CPU architecture of the device that is reporting the error
*/
- accessibility?: ViewAccessibilityProperties
- [k: string]: unknown
- }
- /**
- * Session properties
- */
- readonly session?: {
+ readonly architecture?: string
/**
- * Whether this session is currently active. Set to false to manually stop a session
+ * The user’s locale as a language tag combining language and region, e.g. 'en-US'.
*/
- readonly is_active?: boolean
+ readonly locale?: string
/**
- * Whether this session has been sampled for replay
+ * Ordered list of the user’s preferred system languages as IETF language tags.
*/
- readonly sampled_for_replay?: boolean
- [k: string]: unknown
- }
- /**
- * Feature flags properties
- */
- readonly feature_flags?: {
- [k: string]: unknown
- }
- /**
- * Privacy properties
- */
- readonly privacy?: {
+ readonly locales?: string[]
/**
- * The replay privacy level
+ * The device’s current time zone identifier, e.g. 'Europe/Berlin'.
*/
- readonly replay_level: 'allow' | 'mask' | 'mask-user-input'
- [k: string]: unknown
- }
- /**
- * Internal properties
- */
- readonly _dd: {
+ readonly time_zone?: string
/**
- * Version of the update of the view event
+ * Current battery level of the device (0.0 to 1.0).
*/
- readonly document_version: number
+ readonly battery_level?: number
/**
- * List of the page states during the view
+ * Whether the device is in power saving mode.
*/
- readonly page_states?: {
- /**
- * Page state name
- */
- readonly state: 'active' | 'passive' | 'hidden' | 'frozen' | 'terminated'
- /**
- * Duration in ns between start of the view and start of the page state
- */
- readonly start: number
- [k: string]: unknown
- }[]
+ readonly power_saving_mode?: boolean
/**
- * Debug metadata for Replay Sessions
+ * Current screen brightness level (0.0 to 1.0).
*/
- replay_stats?: {
- /**
- * The number of records produced during this view lifetime
- */
- records_count?: number
- /**
- * The number of segments sent during this view lifetime
- */
- segments_count?: number
- /**
- * The total size in bytes of the segments sent during this view lifetime
- */
- segments_total_raw_size?: number
- [k: string]: unknown
- }
+ readonly brightness_level?: number
/**
- * Additional information of the reported Cumulative Layout Shift
+ * Number of logical CPU cores available for scheduling on the device at runtime, as reported by the operating system.
*/
- readonly cls?: {
- /**
- * Pixel ratio of the device where the layout shift was reported
- */
- readonly device_pixel_ratio?: number
- [k: string]: unknown
- }
+ readonly logical_cpu_count?: number
/**
- * Subset of the SDK configuration options in use during its execution
+ * Total RAM in megabytes
*/
- readonly configuration?: {
- /**
- * Whether session replay recording configured to start manually
- */
- readonly start_session_replay_recording_manually?: boolean
- [k: string]: unknown
- }
+ readonly total_ram?: number
/**
- * Profiling context
+ * Whether the device is considered a low RAM device (Android)
*/
- profiling?: ProfilingInternalContextSchema
+ readonly is_low_ram?: boolean
[k: string]: unknown
}
/**
- * Display properties
+ * User provided context
*/
- readonly display?: {
- /**
- * Scroll properties
- */
- readonly scroll?: {
- /**
- * Distance between the top and the lowest point reached on this view (in pixels)
- */
- readonly max_depth: number
- /**
- * Page scroll top (scrolled distance) when the maximum scroll depth was reached for this view (in pixels)
- */
- readonly max_depth_scroll_top: number
- /**
- * Maximum page scroll height (total height) for this view (in pixels)
- */
- readonly max_scroll_height: number
- /**
- * Duration between the view start and the time the max scroll height was reached for this view (in nanoseconds)
- */
- readonly max_scroll_height_time: number
- [k: string]: unknown
- }
+ context?: {
[k: string]: unknown
}
[k: string]: unknown
@@ -1920,6 +1946,410 @@ export interface StreamSchema {
}
[k: string]: unknown
}
+/**
+ * Shared optional view-specific properties used by both view and view_update events
+ */
+export interface ViewProperties {
+ /**
+ * View properties
+ */
+ readonly view?: {
+ /**
+ * Duration in ns to the view is considered loaded
+ */
+ readonly loading_time?: number
+ /**
+ * Duration in ns from the moment the view was started until all the initial network requests settled
+ */
+ readonly network_settled_time?: number
+ /**
+ * Duration in ns to from the last interaction on previous view to the moment the current view was displayed
+ */
+ readonly interaction_to_next_view_time?: number
+ /**
+ * Type of the loading of the view
+ */
+ readonly loading_type?:
+ | 'initial_load'
+ | 'route_change'
+ | 'activity_display'
+ | 'activity_redisplay'
+ | 'fragment_display'
+ | 'fragment_redisplay'
+ | 'view_controller_display'
+ | 'view_controller_redisplay'
+ /**
+ * Time spent on the view in ns
+ */
+ readonly time_spent?: number
+ /**
+ * @deprecated
+ * Duration in ns to the first rendering (deprecated in favor of `view.performance.fcp.timestamp`)
+ */
+ readonly first_contentful_paint?: number
+ /**
+ * @deprecated
+ * Duration in ns to the largest contentful paint (deprecated in favor of `view.performance.lcp.timestamp`)
+ */
+ readonly largest_contentful_paint?: number
+ /**
+ * @deprecated
+ * CSS selector path of the largest contentful paint element (deprecated in favor of `view.performance.lcp.target_selector`)
+ */
+ readonly largest_contentful_paint_target_selector?: string
+ /**
+ * @deprecated
+ * Duration in ns of the first input event delay (deprecated in favor of `view.performance.fid.duration`)
+ */
+ readonly first_input_delay?: number
+ /**
+ * @deprecated
+ * Duration in ns to the first input (deprecated in favor of `view.performance.fid.timestamp`)
+ */
+ readonly first_input_time?: number
+ /**
+ * @deprecated
+ * CSS selector path of the first input target element (deprecated in favor of `view.performance.fid.target_selector`)
+ */
+ readonly first_input_target_selector?: string
+ /**
+ * @deprecated
+ * Longest duration in ns between an interaction and the next paint (deprecated in favor of `view.performance.inp.duration`)
+ */
+ readonly interaction_to_next_paint?: number
+ /**
+ * @deprecated
+ * Duration in ns between start of the view and start of the INP (deprecated in favor of `view.performance.inp.timestamp`)
+ */
+ readonly interaction_to_next_paint_time?: number
+ /**
+ * @deprecated
+ * CSS selector path of the interacted element corresponding to INP (deprecated in favor of `view.performance.inp.target_selector`)
+ */
+ readonly interaction_to_next_paint_target_selector?: string
+ /**
+ * @deprecated
+ * Total layout shift score that occurred on the view (deprecated in favor of `view.performance.cls.score`)
+ */
+ readonly cumulative_layout_shift?: number
+ /**
+ * @deprecated
+ * Duration in ns between start of the view and start of the largest layout shift contributing to CLS (deprecated in favor of `view.performance.cls.timestamp`)
+ */
+ readonly cumulative_layout_shift_time?: number
+ /**
+ * @deprecated
+ * CSS selector path of the first element (in document order) of the largest layout shift contributing to CLS (deprecated in favor of `view.performance.cls.target_selector`)
+ */
+ readonly cumulative_layout_shift_target_selector?: string
+ /**
+ * Duration in ns to the complete parsing and loading of the document and its sub resources
+ */
+ readonly dom_complete?: number
+ /**
+ * Duration in ns to the complete parsing and loading of the document without its sub resources
+ */
+ readonly dom_content_loaded?: number
+ /**
+ * Duration in ns to the end of the parsing of the document
+ */
+ readonly dom_interactive?: number
+ /**
+ * Duration in ns to the end of the load event handler execution
+ */
+ readonly load_event?: number
+ /**
+ * Duration in ns to the response start of the document request
+ */
+ readonly first_byte?: number
+ /**
+ * User custom timings of the view. As timing name is used as facet path, it must contain only letters, digits, or the characters - _ . @ $
+ */
+ readonly custom_timings?: {
+ [k: string]: number
+ }
+ /**
+ * Whether the View corresponding to this event is considered active
+ */
+ readonly is_active?: boolean
+ /**
+ * Whether the View had a low average refresh rate
+ */
+ readonly is_slow_rendered?: boolean
+ /**
+ * Properties of the actions of the view
+ */
+ readonly action?: {
+ /**
+ * Number of actions that occurred on the view
+ */
+ readonly count?: number
+ [k: string]: unknown
+ }
+ /**
+ * Properties of the errors of the view
+ */
+ readonly error?: {
+ /**
+ * Number of errors that occurred on the view
+ */
+ readonly count?: number
+ [k: string]: unknown
+ }
+ /**
+ * Properties of the crashes of the view
+ */
+ readonly crash?: {
+ /**
+ * Number of crashes that occurred on the view
+ */
+ readonly count?: number
+ [k: string]: unknown
+ }
+ /**
+ * Properties of the long tasks of the view
+ */
+ readonly long_task?: {
+ /**
+ * Number of long tasks that occurred on the view
+ */
+ readonly count?: number
+ [k: string]: unknown
+ }
+ /**
+ * Properties of the frozen frames of the view
+ */
+ readonly frozen_frame?: {
+ /**
+ * Number of frozen frames that occurred on the view
+ */
+ readonly count?: number
+ [k: string]: unknown
+ }
+ /**
+ * List of slow frames during the view's lifetime
+ */
+ readonly slow_frames?: {
+ /**
+ * Duration in ns between start of the view and the start of the slow frame
+ */
+ readonly start: number
+ /**
+ * Duration in ns of the slow frame
+ */
+ readonly duration: number
+ [k: string]: unknown
+ }[]
+ /**
+ * Properties of the resources of the view
+ */
+ readonly resource?: {
+ /**
+ * Number of resources that occurred on the view
+ */
+ readonly count?: number
+ [k: string]: unknown
+ }
+ /**
+ * Properties of the frustrations of the view
+ */
+ readonly frustration?: {
+ /**
+ * Number of frustrations that occurred on the view
+ */
+ readonly count?: number
+ [k: string]: unknown
+ }
+ /**
+ * List of the periods of time the user had the view in foreground (focused in the browser)
+ */
+ readonly in_foreground_periods?: {
+ /**
+ * Duration in ns between start of the view and start of foreground period
+ */
+ readonly start: number
+ /**
+ * Duration in ns of the view foreground period
+ */
+ readonly duration: number
+ [k: string]: unknown
+ }[]
+ /**
+ * Average memory used during the view lifetime (in bytes)
+ */
+ readonly memory_average?: number
+ /**
+ * Peak memory used during the view lifetime (in bytes)
+ */
+ readonly memory_max?: number
+ /**
+ * Total number of cpu ticks during the view's lifetime
+ */
+ readonly cpu_ticks_count?: number
+ /**
+ * Average number of cpu ticks per second during the view's lifetime
+ */
+ readonly cpu_ticks_per_second?: number
+ /**
+ * Average refresh rate during the view's lifetime (in frames per second)
+ */
+ readonly refresh_rate_average?: number
+ /**
+ * Minimum refresh rate during the view's lifetime (in frames per second)
+ */
+ readonly refresh_rate_min?: number
+ /**
+ * Rate of slow frames during the view's lifetime (in milliseconds per second)
+ */
+ readonly slow_frames_rate?: number
+ /**
+ * Rate of freezes during the view's lifetime (in seconds per hour)
+ */
+ readonly freeze_rate?: number
+ /**
+ * Time taken for Flutter 'build' methods.
+ */
+ flutter_build_time?: RumPerfMetric
+ /**
+ * Time taken for Flutter to rasterize the view.
+ */
+ flutter_raster_time?: RumPerfMetric
+ /**
+ * The JavaScript refresh rate for React Native
+ */
+ js_refresh_rate?: RumPerfMetric
+ /**
+ * Performance data. (Web Vitals, etc.)
+ */
+ performance?: ViewPerformanceData
+ /**
+ * Accessibility properties of the view
+ */
+ accessibility?: ViewAccessibilityProperties
+ [k: string]: unknown
+ }
+ /**
+ * Session properties
+ */
+ readonly session?: {
+ /**
+ * Whether this session is currently active. Set to false to manually stop a session
+ */
+ readonly is_active?: boolean
+ /**
+ * Whether this session has been sampled for replay
+ */
+ readonly sampled_for_replay?: boolean
+ [k: string]: unknown
+ }
+ /**
+ * Feature flags properties
+ */
+ readonly feature_flags?: {
+ [k: string]: unknown
+ }
+ /**
+ * Privacy properties
+ */
+ readonly privacy?: {
+ /**
+ * The replay privacy level
+ */
+ readonly replay_level?: 'allow' | 'mask' | 'mask-user-input'
+ [k: string]: unknown
+ }
+ /**
+ * Internal properties
+ */
+ readonly _dd?: {
+ /**
+ * List of the page states during the view
+ */
+ readonly page_states?: {
+ /**
+ * Page state name
+ */
+ readonly state: 'active' | 'passive' | 'hidden' | 'frozen' | 'terminated'
+ /**
+ * Duration in ns between start of the view and start of the page state
+ */
+ readonly start: number
+ [k: string]: unknown
+ }[]
+ /**
+ * Debug metadata for Replay Sessions
+ */
+ replay_stats?: {
+ /**
+ * The number of records produced during this view lifetime
+ */
+ records_count?: number
+ /**
+ * The number of segments sent during this view lifetime
+ */
+ segments_count?: number
+ /**
+ * The total size in bytes of the segments sent during this view lifetime
+ */
+ segments_total_raw_size?: number
+ [k: string]: unknown
+ }
+ /**
+ * Additional information of the reported Cumulative Layout Shift
+ */
+ readonly cls?: {
+ /**
+ * Pixel ratio of the device where the layout shift was reported
+ */
+ readonly device_pixel_ratio?: number
+ [k: string]: unknown
+ }
+ /**
+ * Subset of the SDK configuration options in use during its execution
+ */
+ readonly configuration?: {
+ /**
+ * Whether session replay recording configured to start manually
+ */
+ readonly start_session_replay_recording_manually?: boolean
+ [k: string]: unknown
+ }
+ /**
+ * Profiling context
+ */
+ profiling?: ProfilingInternalContextSchema
+ [k: string]: unknown
+ }
+ /**
+ * Display properties
+ */
+ readonly display?: {
+ /**
+ * Scroll properties
+ */
+ readonly scroll?: {
+ /**
+ * Distance between the top and the lowest point reached on this view (in pixels)
+ */
+ readonly max_depth?: number
+ /**
+ * Page scroll top (scrolled distance) when the maximum scroll depth was reached for this view (in pixels)
+ */
+ readonly max_depth_scroll_top?: number
+ /**
+ * Maximum page scroll height (total height) for this view (in pixels)
+ */
+ readonly max_scroll_height?: number
+ /**
+ * Duration between the view start and the time the max scroll height was reached for this view (in nanoseconds)
+ */
+ readonly max_scroll_height_time?: number
+ [k: string]: unknown
+ }
+ [k: string]: unknown
+ }
+ [k: string]: unknown
+}
/**
* Schema of properties for a technical performance metric
*/
@@ -2016,6 +2446,24 @@ export interface ViewPerformanceData {
* CSS selector path of the interacted element for the INP interaction
*/
readonly target_selector?: string
+ /**
+ * Sub-parts of the INP
+ */
+ sub_parts?: {
+ /**
+ * Time from the start of the input event to the start of the processing of the event
+ */
+ readonly input_delay: number
+ /**
+ * Event handler execution time
+ */
+ readonly processing_time: number
+ /**
+ * Rendering time happening after processing
+ */
+ readonly presentation_delay: number
+ [k: string]: unknown
+ }
[k: string]: unknown
}
/**
diff --git a/packages/rum-core/src/transport/startRumBatch.ts b/packages/rum-core/src/transport/startRumBatch.ts
index d4e6b31920..8a3a9943d1 100644
--- a/packages/rum-core/src/transport/startRumBatch.ts
+++ b/packages/rum-core/src/transport/startRumBatch.ts
@@ -32,6 +32,7 @@ export function startRumBatch(
if (serverRumEvent.type === RumEventType.VIEW) {
batch.upsert(serverRumEvent, serverRumEvent.view.id)
} else {
+ // All other event types (including VIEW_UPDATE) are appended
batch.add(serverRumEvent)
}
})
diff --git a/packages/rum-core/test/fixtures.ts b/packages/rum-core/test/fixtures.ts
index d5c15aceff..c450ab3fa2 100644
--- a/packages/rum-core/test/fixtures.ts
+++ b/packages/rum-core/test/fixtures.ts
@@ -119,6 +119,18 @@ export function createRawRumEvent(type: RumEventType, overrides?: Context): RawR
},
overrides
)
+ case RumEventType.VIEW_UPDATE:
+ return combine(
+ {
+ type,
+ date: 0 as TimeStamp,
+ view: {},
+ _dd: {
+ document_version: 1,
+ },
+ },
+ overrides
+ )
}
}
diff --git a/packages/rum/src/types/profiling.ts b/packages/rum/src/types/profiling.ts
index bf751ccade..47a052e10d 100644
--- a/packages/rum/src/types/profiling.ts
+++ b/packages/rum/src/types/profiling.ts
@@ -24,6 +24,32 @@ export type BrowserProfileEvent = ProfileCommonProperties & {
*/
readonly clock_drift: number
}
+ /**
+ * Action properties.
+ */
+ readonly action?: {
+ /**
+ * Array of action IDs.
+ */
+ readonly id: string[]
+ /**
+ * Array of action labels.
+ */
+ readonly label: string[]
+ }
+ /**
+ * Vital properties.
+ */
+ readonly vital?: {
+ /**
+ * Array of vital IDs.
+ */
+ readonly id: string[]
+ /**
+ * Array of vital labels.
+ */
+ readonly label: string[]
+ }
}
/**
@@ -130,6 +156,14 @@ export interface BrowserProfilerTrace {
* List of detected long tasks.
*/
readonly longTasks: RumProfilerLongTaskEntry[]
+ /**
+ * List of detected vital entries.
+ */
+ readonly vitals?: RumProfilerVitalEntry[]
+ /**
+ * List of detected action entries.
+ */
+ readonly actions?: RumProfilerActionEntry[]
/**
* List of detected navigation entries.
*/
@@ -204,7 +238,7 @@ export interface RumProfilerLongTaskEntry {
*/
readonly id?: string
/**
- * Duration in ns of the long task or long animation frame.
+ * Duration in ms of the long task or long animation frame.
*/
readonly duration: number
/**
@@ -213,6 +247,42 @@ export interface RumProfilerLongTaskEntry {
readonly entryType: 'longtask' | 'long-animation-frame'
startClocks: ClocksState
}
+/**
+ * Schema of a vital entry recorded during profiling.
+ */
+export interface RumProfilerVitalEntry {
+ /**
+ * RUM Vital id.
+ */
+ readonly id: string
+ /**
+ * RUM Vital label.
+ */
+ readonly label: string
+ /**
+ * Duration in ms of the vital.
+ */
+ readonly duration?: number
+ startClocks: ClocksState
+}
+/**
+ * Schema of a action entry recorded during profiling.
+ */
+export interface RumProfilerActionEntry {
+ /**
+ * RUM Action id.
+ */
+ readonly id: string
+ /**
+ * RUM Action label.
+ */
+ readonly label: string
+ /**
+ * Duration in ms of the duration vital.
+ */
+ readonly duration?: number
+ startClocks: ClocksState
+}
/**
* Schema of a RUM view entry recorded during profiling.
*/
diff --git a/rum-events-format b/rum-events-format
index 8dc61166ee..9dfc9a4b84 160000
--- a/rum-events-format
+++ b/rum-events-format
@@ -1 +1 @@
-Subproject commit 8dc61166ee818608892d13b6565ff04a3f2a7fe9
+Subproject commit 9dfc9a4b84ef951d590256a30d0a299b5cd0cef3
diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts
index d0ffee0752..af80e55703 100644
--- a/test/e2e/scenario/rum/actions.scenario.ts
+++ b/test/e2e/scenario/rum/actions.scenario.ts
@@ -233,8 +233,8 @@ test.describe('action collection', () => {
const viewEvents = intakeRegistry.rumViewEvents
const originalViewEvent = viewEvents.find((view) => view.view.url.endsWith('/'))!
const otherViewEvent = viewEvents.find((view) => view.view.url.endsWith('/other-view'))!
- expect(originalViewEvent.view.action.count).toBe(1)
- expect(otherViewEvent.view.action.count).toBe(0)
+ expect(originalViewEvent.view.action!.count).toBe(1)
+ expect(otherViewEvent.view.action!.count).toBe(0)
})
createTest('collect an "error click"')
diff --git a/test/e2e/scenario/rum/partialViewUpdates.scenario.ts b/test/e2e/scenario/rum/partialViewUpdates.scenario.ts
new file mode 100644
index 0000000000..777d9fbe4d
--- /dev/null
+++ b/test/e2e/scenario/rum/partialViewUpdates.scenario.ts
@@ -0,0 +1,172 @@
+import { test, expect } from '@playwright/test'
+import { createTest, html } from '../../lib/framework'
+import type { IntakeRegistry } from '../../lib/framework'
+
+// Loose type for view_update events received at the intake (no generated schema type yet)
+interface ViewUpdateEvent {
+ type: string
+ date: number
+ application: { id: string }
+ session: { id: string }
+ view: { id: string; is_active?: boolean; [key: string]: unknown }
+ _dd: { document_version: number; [key: string]: unknown }
+ [key: string]: unknown
+}
+
+// Helper: extract view_update events from all RUM events
+// (intakeRegistry.rumViewEvents only returns type==='view')
+function getViewUpdateEvents(intakeRegistry: IntakeRegistry): ViewUpdateEvent[] {
+ return intakeRegistry.rumEvents.filter((e) => (e.type as string) === 'view_update') as unknown as ViewUpdateEvent[]
+}
+
+test.describe('partial view updates', () => {
+ createTest('should send view_update events after the initial view event')
+ .withRum({
+ enableExperimentalFeatures: ['partial_view_updates'],
+ })
+ .run(async ({ intakeRegistry, flushEvents, page }) => {
+ // Trigger a user action to cause a view update with changed metrics
+ await page.evaluate(() => {
+ window.DD_RUM!.addAction('test-action')
+ })
+
+ await flushEvents()
+
+ // First event should be type 'view'
+ const viewEvents = intakeRegistry.rumViewEvents
+ expect(viewEvents.length).toBeGreaterThanOrEqual(1)
+ expect(viewEvents[0].type).toBe('view')
+
+ // Should have at least one view_update
+ const viewUpdateEvents = getViewUpdateEvents(intakeRegistry)
+ expect(viewUpdateEvents.length).toBeGreaterThanOrEqual(1)
+
+ // All events share the same view.id
+ const viewId = viewEvents[0].view.id
+ for (const update of viewUpdateEvents) {
+ expect(update.view.id).toBe(viewId)
+ }
+ })
+
+ createTest('should have monotonically increasing document_version across view and view_update events')
+ .withRum({
+ enableExperimentalFeatures: ['partial_view_updates'],
+ })
+ .run(async ({ intakeRegistry, flushEvents, page }) => {
+ await page.evaluate(() => {
+ window.DD_RUM!.addAction('test-action')
+ })
+
+ await flushEvents()
+
+ // Collect all view-related events (view + view_update) sorted by document_version
+ const allViewRelatedEvents = [
+ ...intakeRegistry.rumViewEvents.map((e) => ({ _dd: e._dd })),
+ ...getViewUpdateEvents(intakeRegistry).map((e) => ({ _dd: e._dd })),
+ ].sort((a, b) => a._dd.document_version - b._dd.document_version)
+
+ expect(allViewRelatedEvents.length).toBeGreaterThanOrEqual(2)
+
+ // Verify monotonic increase
+ for (let i = 1; i < allViewRelatedEvents.length; i++) {
+ expect(allViewRelatedEvents[i]._dd.document_version).toBeGreaterThan(
+ allViewRelatedEvents[i - 1]._dd.document_version
+ )
+ }
+ })
+
+ createTest('should only send view events when feature flag is not enabled')
+ .withRum()
+ .run(async ({ intakeRegistry, flushEvents, page }) => {
+ await page.evaluate(() => {
+ window.DD_RUM!.addAction('test-action')
+ })
+
+ await flushEvents()
+
+ // Should have view events
+ expect(intakeRegistry.rumViewEvents.length).toBeGreaterThanOrEqual(1)
+
+ // Should NOT have any view_update events
+ const viewUpdateEvents = getViewUpdateEvents(intakeRegistry)
+ expect(viewUpdateEvents).toHaveLength(0)
+ })
+
+ createTest('should emit a new full view event after navigation')
+ .withRum({
+ enableExperimentalFeatures: ['partial_view_updates'],
+ })
+ .withBody(html`
+ Navigate
+
+ `)
+ .run(async ({ intakeRegistry, flushEvents, page }) => {
+ // Trigger a route change to create a new view
+ await page.click('#nav-link')
+
+ await flushEvents()
+
+ // Should have at least 2 full view events (one per view.id)
+ const viewEvents = intakeRegistry.rumViewEvents
+ expect(viewEvents.length).toBeGreaterThanOrEqual(2)
+
+ // The two view events should have different view.ids
+ const viewIds = new Set(viewEvents.map((e) => e.view.id))
+ expect(viewIds.size).toBeGreaterThanOrEqual(2)
+ })
+
+ createTest('should include required fields in all view_update events')
+ .withRum({
+ enableExperimentalFeatures: ['partial_view_updates'],
+ })
+ .run(async ({ intakeRegistry, flushEvents, page }) => {
+ await page.evaluate(() => {
+ window.DD_RUM!.addAction('test-action')
+ })
+
+ await flushEvents()
+
+ const viewUpdateEvents = getViewUpdateEvents(intakeRegistry)
+ expect(viewUpdateEvents.length).toBeGreaterThanOrEqual(1)
+
+ for (const event of viewUpdateEvents) {
+ // Required fields per spec FR-3
+ expect(event.type).toBe('view_update')
+ expect(event.application.id).toBeDefined()
+ expect(event.session.id).toBeDefined()
+ expect(event.view.id).toBeDefined()
+ expect(event._dd.document_version).toBeDefined()
+ expect(event.date).toBeDefined()
+ }
+ })
+
+ createTest('should send view_update with is_active false when view ends')
+ .withRum({
+ enableExperimentalFeatures: ['partial_view_updates'],
+ })
+ .withBody(html`
+ Navigate
+
+ `)
+ .run(async ({ intakeRegistry, flushEvents, page }) => {
+ // Navigate to trigger view end on the first view
+ await page.click('#nav-link')
+
+ await flushEvents()
+
+ const viewUpdateEvents = getViewUpdateEvents(intakeRegistry)
+
+ // Find the view_update that marks the first view as inactive
+ const firstViewId = intakeRegistry.rumViewEvents[0].view.id
+ const endEvent = viewUpdateEvents.find((e) => e.view.id === firstViewId && e.view.is_active === false)
+ expect(endEvent).toBeDefined()
+ })
+})