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() + }) +})