diff --git a/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx b/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx index 106ce30f71..293ca5989c 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', diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts index bc9cafb4d4..8476f55b2c 100644 --- a/packages/core/src/tools/experimentalFeatures.ts +++ b/packages/core/src/tools/experimentalFeatures.ts @@ -23,6 +23,8 @@ export enum ExperimentalFeature { USE_CHANGE_RECORDS = 'use_change_records', SOURCE_CODE_CONTEXT = 'source_code_context', LCP_SUBPARTS = 'lcp_subparts', + VIEW_UPDATE = 'view_update', + VIEW_UPDATE_CHAOS = 'view_update_chaos', } 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 d0452bd27f..f6c6e8022d 100644 --- a/packages/rum-core/src/domain/assembly.spec.ts +++ b/packages/rum-core/src/domain/assembly.spec.ts @@ -332,7 +332,7 @@ describe('rum assembly', () => { }) expect(serverRumEvents[0].view.id).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') - expect(displaySpy).toHaveBeenCalledWith("Can't dismiss view events using beforeSend!") + expect(displaySpy).toHaveBeenCalledWith("Can't dismiss view or view_update events using beforeSend!") }) }) @@ -570,6 +570,60 @@ describe('rum assembly', () => { }) }) }) + + describe('view_update events', () => { + 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, { + view: { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' }, + }), + }) + + expect(serverRumEvents.length).toBe(1) + expect(displaySpy).toHaveBeenCalledWith("Can't dismiss view or view_update events using beforeSend!") + }) + + it('should allow modification of view_update events via beforeSend', () => { + const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ + partialConfiguration: { + beforeSend: (event) => { + event.view.name = 'modified name' + }, + }, + }) + + notifyRawRumEvent(lifeCycle, { + rawRumEvent: createRawRumEvent(RumEventType.VIEW_UPDATE), + }) + + expect(serverRumEvents[0].view.name).toBe('modified name') + }) + + it('should not rate-limit view_update events', () => { + const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ + eventRateLimit: 1, + }) + + notifyRawRumEvent(lifeCycle, { + rawRumEvent: createRawRumEvent(RumEventType.VIEW_UPDATE, { date: 100 as TimeStamp }), + }) + notifyRawRumEvent(lifeCycle, { + rawRumEvent: createRawRumEvent(RumEventType.VIEW_UPDATE, { date: 200 as TimeStamp }), + }) + notifyRawRumEvent(lifeCycle, { + rawRumEvent: createRawRumEvent(RumEventType.VIEW_UPDATE, { date: 300 as TimeStamp }), + }) + + expect(serverRumEvents.length).toBe(3) + }) + }) }) function notifyRawRumEvent( diff --git a/packages/rum-core/src/domain/assembly.ts b/packages/rum-core/src/domain/assembly.ts index e1b32dfc1c..6c422c0e25 100644 --- a/packages/rum-core/src/domain/assembly.ts +++ b/packages/rum-core/src/domain/assembly.ts @@ -83,6 +83,11 @@ export function startRumAssembly( ...VIEW_MODIFIABLE_FIELD_PATHS, ...ROOT_MODIFIABLE_FIELD_PATHS, }, + [RumEventType.VIEW_UPDATE]: { + ...USER_CUSTOMIZABLE_FIELD_PATHS, + ...VIEW_MODIFIABLE_FIELD_PATHS, + ...ROOT_MODIFIABLE_FIELD_PATHS, + }, } const eventRateLimiters = { [RumEventType.ERROR]: createEventRateLimiter(RumEventType.ERROR, reportError, eventRateLimit), @@ -129,11 +134,11 @@ function shouldSend( const result = limitModification(event, modifiableFieldPathsByEvent[event.type], (event) => beforeSend(event, domainContext) ) - if (result === false && event.type !== RumEventType.VIEW) { + if (result === false && event.type !== RumEventType.VIEW && event.type !== RumEventType.VIEW_UPDATE) { return false } if (result === false) { - display.warn("Can't dismiss view events using beforeSend!") + display.warn("Can't dismiss view or view_update events using beforeSend!") } } diff --git a/packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts b/packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts index bca87d705f..71d3bdf283 100644 --- a/packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/featureFlagContext.spec.ts @@ -58,6 +58,25 @@ describe('featureFlagContexts', () => { }, }) }) + it('should add feature flag evaluations on VIEW_UPDATE by default', () => { + lifeCycle.notify(LifeCycleEventType.BEFORE_VIEW_CREATED, { + startClocks: relativeToClocks(0 as RelativeTime), + } as ViewCreatedEvent) + + featureFlagContexts.addFeatureFlagEvaluation('feature', 'foo') + + const defaultViewUpdateAttributes = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view_update', + startTime: 0 as RelativeTime, + } as AssembleHookParams) + + expect(defaultViewUpdateAttributes).toEqual({ + type: 'view_update', + feature_flags: { + feature: 'foo', + }, + }) + }) ;[RumEventType.VITAL, RumEventType.ACTION, RumEventType.LONG_TASK, RumEventType.RESOURCE].forEach((eventType) => { it(`should add feature flag evaluations on ${eventType} when specified in trackFeatureFlagsForEvents`, () => { trackFeatureFlagsForEvents.push(eventType) 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/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index 15009e3786..6b6caea91a 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -128,6 +128,20 @@ describe('session context', () => { expect(eventSampledOutForReplay.session!.sampled_for_replay).toBe(false) }) + it('should set hasReplay and sampled_for_replay on view_update events', () => { + getReplayStatsSpy.and.returnValue(fakeStats) + sessionManager.setTrackedWithSessionReplay() + + const viewUpdateAttributes = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view_update', + startTime: 0 as RelativeTime, + } as AssembleHookParams) as DefaultRumEventAttributes + + expect(getReplayStatsSpy).toHaveBeenCalled() + expect(viewUpdateAttributes.session!.has_replay).toEqual(true) + expect(viewUpdateAttributes.session!.sampled_for_replay).toBe(true) + }) + it('should discard the event if no session', () => { sessionManager.setNotTracked() const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index 6d114a5242..5d571cf47d 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -23,7 +23,7 @@ export function startSessionContext( let hasReplay let sampledForReplay let isActive - if (eventType === RumEventType.VIEW) { + if (eventType === RumEventType.VIEW || eventType === RumEventType.VIEW_UPDATE) { hasReplay = recorderApi.getReplayStats(view.id) ? true : undefined sampledForReplay = session.sessionReplay === SessionReplayState.SAMPLED isActive = view.sessionIsActive ? undefined : false diff --git a/packages/rum-core/src/domain/trackEventCounts.spec.ts b/packages/rum-core/src/domain/trackEventCounts.spec.ts index 4b707bdb12..e14cc508dd 100644 --- a/packages/rum-core/src/domain/trackEventCounts.spec.ts +++ b/packages/rum-core/src/domain/trackEventCounts.spec.ts @@ -26,7 +26,7 @@ describe('trackEventCounts', () => { notifyCollectedRawRumEvent({ type: RumEventType.LONG_TASK }) expect(eventCounts.longTaskCount).toBe(1) }) - ;[RumEventType.VIEW, RumEventType.VITAL].forEach((eventType) => { + ;[RumEventType.VIEW, RumEventType.VIEW_UPDATE, RumEventType.VITAL].forEach((eventType) => { it(`doesn't track ${eventType} events`, () => { const { eventCounts } = trackEventCounts({ lifeCycle, isChildEvent: () => true }) notifyCollectedRawRumEvent({ type: eventType }) diff --git a/packages/rum-core/src/domain/trackEventCounts.ts b/packages/rum-core/src/domain/trackEventCounts.ts index c7a0cc856b..8703643126 100644 --- a/packages/rum-core/src/domain/trackEventCounts.ts +++ b/packages/rum-core/src/domain/trackEventCounts.ts @@ -30,7 +30,7 @@ export function trackEventCounts({ } const subscription = lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event): void => { - if (event.type === 'view' || event.type === 'vital' || !isChildEvent(event)) { + if (event.type === 'view' || event.type === 'view_update' || event.type === 'vital' || !isChildEvent(event)) { return } switch (event.type) { diff --git a/packages/rum-core/src/domain/view/viewCollection.spec.ts b/packages/rum-core/src/domain/view/viewCollection.spec.ts index 56e4925f0b..f0625c04e0 100644 --- a/packages/rum-core/src/domain/view/viewCollection.spec.ts +++ b/packages/rum-core/src/domain/view/viewCollection.spec.ts @@ -1,9 +1,9 @@ -import { DISCARDED, HookNames, Observable } from '@datadog/browser-core' +import { addExperimentalFeatures, DISCARDED, ExperimentalFeature, HookNames, Observable } 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' import { collectAndValidateRawRumEvents, mockRumConfiguration, mockViewHistory, noopRecorderApi } from '../../../test' -import type { RawRumEvent, RawRumViewEvent } from '../../rawRumEvent.types' +import type { RawRumEvent, RawRumViewEvent, RawRumViewUpdateEvent } from '../../rawRumEvent.types' import { RumEventType, ViewLoadingType } from '../../rawRumEvent.types' import type { RawRumEventCollectedData } from '../lifeCycle' import { LifeCycle, LifeCycleEventType } from '../lifeCycle' @@ -284,6 +284,405 @@ describe('viewCollection', () => { }) }) + describe('view_update feature flag', () => { + it('emits full view event for document_version=1', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...VIEW, documentVersion: 1 }) + expect(rawRumEvents[rawRumEvents.length - 1].rawRumEvent.type).toBe(RumEventType.VIEW) + }) + + it('emits view_update for document_version > 1 after snapshot established', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + // First event: doc_version=1 establishes snapshot + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...VIEW, + documentVersion: 1, + isActive: true, + id: 'test-view-id', + }) + // Second event: doc_version=2 should diff + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...VIEW, + documentVersion: 2, + isActive: true, + id: 'test-view-id', + }) + expect(rawRumEvents[rawRumEvents.length - 1].rawRumEvent.type).toBe(RumEventType.VIEW_UPDATE) + }) + + it('emits view_update with only changed action count', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { + ...VIEW, + isActive: true, + id: 'test-view-id', + eventCounts: { ...VIEW.eventCounts, actionCount: 5 }, + } + // Establish snapshot + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + // Only action count changed + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 2, + eventCounts: { ...baseView.eventCounts, actionCount: 6 }, + }) + + const event = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect(event.type).toBe(RumEventType.VIEW_UPDATE) + expect(event.view.action).toEqual({ count: 6 }) + expect(event.view.error).toBeUndefined() + }) + + it('emits view_update with only changed error count', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { + ...VIEW, + isActive: true, + id: 'test-view-id', + eventCounts: { ...VIEW.eventCounts, errorCount: 3 }, + } + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 2, + eventCounts: { ...baseView.eventCounts, errorCount: 4 }, + }) + + const event = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect(event.view.error).toEqual({ count: 4 }) + expect(event.view.action).toBeUndefined() + }) + + it('view_update omits unchanged counters', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { ...VIEW, isActive: true, id: 'test-view-id' } + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + // Nothing changed except duration (time_spent always included) + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 2, + duration: 200 as Duration, + }) + + const event = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect(event.type).toBe(RumEventType.VIEW_UPDATE) + expect(event.view.action).toBeUndefined() + expect(event.view.error).toBeUndefined() + expect(event.view.resource).toBeUndefined() + expect(event.view.long_task).toBeUndefined() + expect(event.view.frustration).toBeUndefined() + }) + + it('view_update always includes time_spent, omits is_active', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { ...VIEW, isActive: true, id: 'test-view-id' } + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 2, + duration: 300 as Duration, + isActive: true, + }) + + const event = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect(event.view.time_spent).toBeDefined() + expect((event.view as any).is_active).toBeUndefined() + }) + + it('view_update includes CLS fields when changed, not redundant performance object', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { + ...VIEW, + isActive: true, + id: 'test-view-id', + commonViewMetrics: { + ...VIEW.commonViewMetrics, + cumulativeLayoutShift: { value: 0.1, time: 50 as Duration }, + }, + } + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 2, + commonViewMetrics: { + ...baseView.commonViewMetrics, + cumulativeLayoutShift: { value: 0.5, time: 100 as Duration }, + }, + }) + + const event = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect(event.view.cumulative_layout_shift).toBe(0.5) + expect(event.view.performance).toBeUndefined() + }) + + it('view_update omits CLS and performance when unchanged', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { ...VIEW, isActive: true, id: 'test-view-id' } + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + // Same metrics, only duration changed + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 2, + duration: 500 as Duration, + }) + + const event = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect(event.view.cumulative_layout_shift).toBeUndefined() + expect(event.view.performance).toBeUndefined() + }) + + it('view_update includes custom_timings when changed (full object)', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { + ...VIEW, + isActive: true, + id: 'test-view-id', + customTimings: { foo: 10 as Duration }, + } + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 2, + customTimings: { foo: 10 as Duration, bar: 20 as Duration }, + }) + + const event = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect(event.view.custom_timings).toEqual({ + foo: (10 * 1e6) as ServerDuration, + bar: (20 * 1e6) as ServerDuration, + }) + }) + + it('view_update omits scroll when unchanged (Duration/ServerDuration unit normalization)', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const scroll = { + maxDepth: 500, + maxDepthScrollTop: 100, + maxScrollHeight: 1200, + maxScrollHeightTime: 5000 as Duration, + } + const baseView = { + ...VIEW, + isActive: true, + id: 'test-view-id', + commonViewMetrics: { ...VIEW.commonViewMetrics, scroll }, + } + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + // Same scroll — only duration changed + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 2, + duration: 300 as Duration, + }) + + const event = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect(event.display).toBeUndefined() + }) + + it('view_update includes scroll when changed', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { + ...VIEW, + isActive: true, + id: 'test-view-id', + commonViewMetrics: { + ...VIEW.commonViewMetrics, + scroll: { maxDepth: 300, maxDepthScrollTop: 0, maxScrollHeight: 1000, maxScrollHeightTime: 3000 as Duration }, + }, + } + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 2, + commonViewMetrics: { + ...baseView.commonViewMetrics, + scroll: { + maxDepth: 700, + maxDepthScrollTop: 200, + maxScrollHeight: 1400, + maxScrollHeightTime: 7000 as Duration, + }, + }, + }) + + const event = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect(event.display?.scroll?.max_depth).toBe(700) + expect(event.display?.scroll?.max_scroll_height_time).toBe((7000 * 1e6) as ServerDuration) + }) + + it('view_update omits static fields (loading_type, name)', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { ...VIEW, isActive: true, id: 'test-view-id', name: 'my-view' } + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 2, + duration: 300 as Duration, + }) + + const event = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect((event.view as any).loading_type).toBeUndefined() + expect((event.view as any).name).toBeUndefined() + }) + + it('emits full VIEW on view end (is_active=false)', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { ...VIEW, isActive: true, id: 'test-view-id' } + // Establish snapshot + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + // View end: is_active=false => full VIEW + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 2, + isActive: false, + }) + + expect(rawRumEvents[0].rawRumEvent.type).toBe(RumEventType.VIEW) + }) + + it('emits full VIEW every FULL_VIEW_REFRESH_INTERVAL updates', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { ...VIEW, isActive: true, id: 'test-view-id' } + // doc_version=1 establishes snapshot + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + + // Emit 50 partial updates (versions 2..51) — each should be VIEW_UPDATE + for (let i = 2; i <= 51; i++) { + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: i }) + } + // The 50th diff (version 51) should be VIEW_UPDATE + expect(rawRumEvents[rawRumEvents.length - 1].rawRumEvent.type).toBe(RumEventType.VIEW_UPDATE) + + // The 51st update (version 52) hits FULL_VIEW_REFRESH_INTERVAL=50, so full VIEW + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 52 }) + expect(rawRumEvents[rawRumEvents.length - 1].rawRumEvent.type).toBe(RumEventType.VIEW) + }) + + it('emits full VIEW after FULL_VIEW_REFRESH_TIME elapsed', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { ...VIEW, isActive: true, id: 'test-view-id-time' } + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + // Advance time past FULL_VIEW_REFRESH_TIME (5 * 60_000 ms) + jasmine.clock().tick(5 * 60_000 + 1_000) + + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 2 }) + expect(rawRumEvents[0].rawRumEvent.type).toBe(RumEventType.VIEW) + }) + + it('new view resets snapshot — doc_version=1 emits full VIEW', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...VIEW, + documentVersion: 1, + isActive: true, + id: 'new-view-id', + }) + expect(rawRumEvents[rawRumEvents.length - 1].rawRumEvent.type).toBe(RumEventType.VIEW) + }) + + it('missing snapshot falls back to full VIEW', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + // Emit doc_version=2 without any prior doc_version=1 snapshot + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...VIEW, + documentVersion: 2, + isActive: true, + id: 'no-snapshot-view', + }) + expect(rawRumEvents[rawRumEvents.length - 1].rawRumEvent.type).toBe(RumEventType.VIEW) + }) + + it('snapshot is updated after each view_update', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { + ...VIEW, + isActive: true, + id: 'test-view-snap', + eventCounts: { ...VIEW.eventCounts, actionCount: 1 }, + } + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + // First diff: action count 1 -> 2 + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 2, + eventCounts: { ...baseView.eventCounts, actionCount: 2 }, + }) + const event1 = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect(event1.view.action).toEqual({ count: 2 }) + rawRumEvents.length = 0 + + // Second diff: action count stays at 2 — should be omitted (snapshot was updated) + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { + ...baseView, + documentVersion: 3, + eventCounts: { ...baseView.eventCounts, actionCount: 2 }, + }) + const event2 = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect(event2.view.action).toBeUndefined() + }) + + it('view_update includes replay_stats when changed', () => { + setupViewCollection() + addExperimentalFeatures([ExperimentalFeature.VIEW_UPDATE]) + const baseView = { ...VIEW, isActive: true, id: 'test-view-replay' } + + getReplayStatsSpy.and.returnValue(undefined) + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 1 }) + rawRumEvents.length = 0 + + const newReplayStats = { records_count: 5, segments_count: 1, segments_total_raw_size: 100 } + getReplayStatsSpy.and.returnValue(newReplayStats) + lifeCycle.notify(LifeCycleEventType.VIEW_UPDATED, { ...baseView, documentVersion: 2 }) + + const event = rawRumEvents[0].rawRumEvent as RawRumViewUpdateEvent + expect(event._dd.replay_stats).toEqual(newReplayStats) + }) + }) + describe('assemble telemetry hook', () => { it('should add view id', () => { setupViewCollection({ trackViewsManually: true }, VIEW) diff --git a/packages/rum-core/src/domain/view/viewCollection.ts b/packages/rum-core/src/domain/view/viewCollection.ts index 465b5e46c3..3b79065e4c 100644 --- a/packages/rum-core/src/domain/view/viewCollection.ts +++ b/packages/rum-core/src/domain/view/viewCollection.ts @@ -1,8 +1,18 @@ -import type { Duration, ServerDuration, Observable } from '@datadog/browser-core' -import { getTimeZone, DISCARDED, HookNames, isEmptyObject, mapValues, toServerDuration } from '@datadog/browser-core' +import type { Duration, RelativeTime, ServerDuration, Observable } from '@datadog/browser-core' +import { + ExperimentalFeature, + getTimeZone, + DISCARDED, + HookNames, + isEmptyObject, + isExperimentalFeatureEnabled, + mapValues, + relativeNow, + toServerDuration, +} from '@datadog/browser-core' import { discardNegativeDuration } from '../discardNegativeDuration' import type { RecorderApi } from '../../boot/rumPublicApi' -import type { RawRumViewEvent, ViewPerformanceData } from '../../rawRumEvent.types' +import type { RawRumViewEvent, RawRumViewUpdateEvent, ViewPerformanceData } from '../../rawRumEvent.types' import { RumEventType } from '../../rawRumEvent.types' import type { LifeCycle, RawRumEventCollectedData } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' @@ -16,6 +26,15 @@ import type { ViewEvent, ViewOptions } from './trackViews' import type { CommonViewMetrics } from './viewMetrics/trackCommonViewMetrics' import type { InitialViewMetrics } from './viewMetrics/trackInitialViewMetrics' +const FULL_VIEW_REFRESH_INTERVAL = 50 // Every 50 updates, send full VIEW (safety net) +const FULL_VIEW_REFRESH_TIME = 5 * 60_000 // Or every 5 minutes (aligned with session keep-alive) + +interface ViewSnapshot { + event: RawRumViewEvent + updatesSinceLastFull: number + lastFullViewTime: RelativeTime +} + export function startViewCollection( lifeCycle: LifeCycle, hooks: Hooks, @@ -27,9 +46,56 @@ export function startViewCollection( viewHistory: ViewHistory, initialViewOptions?: ViewOptions ) { - lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, (view) => - lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, processViewUpdate(view, configuration, recorderApi)) - ) + const snapshotStore = new Map() + + const viewUpdatedSubscription = lifeCycle.subscribe(LifeCycleEventType.VIEW_UPDATED, (view) => { + if (view.documentVersion === 1 || !isExperimentalFeatureEnabled(ExperimentalFeature.VIEW_UPDATE)) { + const fullResult = processViewUpdate(view, configuration, recorderApi) + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, fullResult) + + // Store snapshot for diff baseline (only when VIEW_UPDATE feature is enabled) + if (isExperimentalFeatureEnabled(ExperimentalFeature.VIEW_UPDATE)) { + snapshotStore.set(view.id, { + event: fullResult.rawRumEvent, + updatesSinceLastFull: 0, + lastFullViewTime: relativeNow(), + }) + } + } else { + const snapshot = snapshotStore.get(view.id) + const shouldSendFull = + !snapshot || + !view.isActive || // always full on view end + snapshot.updatesSinceLastFull >= FULL_VIEW_REFRESH_INTERVAL || + relativeNow() - snapshot.lastFullViewTime >= FULL_VIEW_REFRESH_TIME + + if (shouldSendFull) { + const fullResult = processViewUpdate(view, configuration, recorderApi) + lifeCycle.notify(LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, fullResult) + snapshotStore.set(view.id, { + event: fullResult.rawRumEvent, + updatesSinceLastFull: 0, + lastFullViewTime: relativeNow(), + }) + } else { + lifeCycle.notify( + LifeCycleEventType.RAW_RUM_EVENT_COLLECTED, + processViewDiff(view, snapshot, configuration, recorderApi) + ) + // Update the snapshot event to the current state so the next diff has the latest baseline + const updatedFullEvent = processViewUpdate(view, configuration, recorderApi).rawRumEvent + snapshotStore.set(view.id, { + event: updatedFullEvent, + updatesSinceLastFull: snapshot.updatesSinceLastFull + 1, + lastFullViewTime: snapshot.lastFullViewTime, + }) + } + + if (!view.isActive) { + snapshotStore.delete(view.id) + } + } + }) hooks.register(HookNames.Assemble, ({ startTime, eventType }): DefaultRumEventAttributes | DISCARDED => { const view = viewHistory.findView(startTime) @@ -59,7 +125,7 @@ export function startViewCollection( }) ) - return trackViews( + const trackViewsResult = trackViews( lifeCycle, domMutationObservable, pageOpenObservable, @@ -68,6 +134,15 @@ export function startViewCollection( !configuration.trackViewsManually, initialViewOptions ) + + return { + ...trackViewsResult, + stop: () => { + viewUpdatedSubscription.unsubscribe() + snapshotStore.clear() + trackViewsResult.stop() + }, + } } function processViewUpdate( @@ -170,6 +245,239 @@ function processViewUpdate( } } +// Compile-time exhaustiveness guard for processViewDiff. +// +// Every field in RawRumViewUpdateEvent['view'] must appear in exactly one of: +// - ALWAYS_PRESENT_VU_VIEW_FIELDS — unconditionally included (e.g. time_spent) +// - DIFFED_VU_VIEW_FIELDS — included when changed (counters, vitals, timings) +// - OMITTED_VU_VIEW_FIELDS — intentionally excluded from VUs with reason +// +// If a new field is added to the schema without updating one of these types, the compiler +// will emit: "Type 'true' is not assignable to type 'never'" on the assertion below. +type AlwaysPresentVuViewFields = 'time_spent' +type DiffedVuViewFields = + | 'error' + | 'action' + | 'long_task' + | 'resource' + | 'frustration' + | 'loading_time' + | 'cumulative_layout_shift' + | 'cumulative_layout_shift_time' + | 'cumulative_layout_shift_target_selector' + | 'interaction_to_next_paint' + | 'interaction_to_next_paint_time' + | 'interaction_to_next_paint_target_selector' + | 'first_contentful_paint' + | 'first_input_delay' + | 'first_input_time' + | 'first_input_target_selector' + | 'largest_contentful_paint' + | 'largest_contentful_paint_target_selector' + | 'dom_complete' + | 'dom_content_loaded' + | 'dom_interactive' + | 'load_event' + | 'first_byte' + | 'custom_timings' +// Fields present in RawRumViewUpdateEvent['view'] but intentionally excluded from VUs: +type OmittedVuViewFields = 'performance' // redundant — each sub-metric is already sent as an individual flat field above +type _AssertVuViewFieldsCovered = + Exclude< + keyof RawRumViewUpdateEvent['view'], + AlwaysPresentVuViewFields | DiffedVuViewFields | OmittedVuViewFields + > extends never + ? true + : never +// If the line below has a type error, add the new field to one of the types above and handle it +// in processViewDiff (or add to OmittedVuViewFields with a comment explaining why). +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _vuViewFieldsCoverage: _AssertVuViewFieldsCovered = true + +function processViewDiff( + view: ViewEvent, + snapshot: ViewSnapshot, + _configuration: RumConfiguration, + recorderApi: RecorderApi +): RawRumEventCollectedData { + const prev = snapshot.event + const replayStats = recorderApi.getReplayStats(view.id) + + // Always-required fields + const viewUpdateEvent: RawRumViewUpdateEvent = { + date: view.startClocks.timeStamp, + type: RumEventType.VIEW_UPDATE, + _dd: { + document_version: view.documentVersion, + replay_stats: JSON.stringify(replayStats) !== JSON.stringify(prev._dd.replay_stats) ? replayStats : undefined, + }, + view: { + // time_spent always changes — omitting it would make every VU ambiguous + time_spent: toServerDuration(view.duration), + // is_active omitted: VUs are only emitted for active views (view-end always sends a full VIEW) + }, + } + + // Replay stats diff (already handled above, but also check for inclusion) + if (JSON.stringify(replayStats) !== JSON.stringify(prev._dd.replay_stats)) { + viewUpdateEvent._dd.replay_stats = replayStats + } + + // Counter diffs + const currentActionCount = view.eventCounts.actionCount + if (currentActionCount !== prev.view.action.count) { + viewUpdateEvent.view.action = { count: currentActionCount } + } + + const currentErrorCount = view.eventCounts.errorCount + if (currentErrorCount !== prev.view.error.count) { + viewUpdateEvent.view.error = { count: currentErrorCount } + } + + const currentLongTaskCount = view.eventCounts.longTaskCount + if (currentLongTaskCount !== prev.view.long_task.count) { + viewUpdateEvent.view.long_task = { count: currentLongTaskCount } + } + + const currentResourceCount = view.eventCounts.resourceCount + if (currentResourceCount !== prev.view.resource.count) { + viewUpdateEvent.view.resource = { count: currentResourceCount } + } + + const currentFrustrationCount = view.eventCounts.frustrationCount + if (currentFrustrationCount !== prev.view.frustration.count) { + viewUpdateEvent.view.frustration = { count: currentFrustrationCount } + } + + // Web vitals / performance + const currentLoadingTime = discardNegativeDuration(toServerDuration(view.commonViewMetrics.loadingTime)) + if (currentLoadingTime !== prev.view.loading_time) { + viewUpdateEvent.view.loading_time = currentLoadingTime + } + + const currentCLS = view.commonViewMetrics.cumulativeLayoutShift?.value + if (currentCLS !== prev.view.cumulative_layout_shift) { + viewUpdateEvent.view.cumulative_layout_shift = currentCLS + viewUpdateEvent.view.cumulative_layout_shift_time = toServerDuration( + view.commonViewMetrics.cumulativeLayoutShift?.time + ) + viewUpdateEvent.view.cumulative_layout_shift_target_selector = + view.commonViewMetrics.cumulativeLayoutShift?.targetSelector + } + + const currentINP = toServerDuration(view.commonViewMetrics.interactionToNextPaint?.value) + if (currentINP !== prev.view.interaction_to_next_paint) { + viewUpdateEvent.view.interaction_to_next_paint = currentINP + viewUpdateEvent.view.interaction_to_next_paint_time = toServerDuration( + view.commonViewMetrics.interactionToNextPaint?.time + ) + viewUpdateEvent.view.interaction_to_next_paint_target_selector = + view.commonViewMetrics.interactionToNextPaint?.targetSelector + } + + // Initial-load only metrics (they only set once — compare with snapshot) + const currentFCP = toServerDuration(view.initialViewMetrics.firstContentfulPaint) + if (currentFCP !== prev.view.first_contentful_paint) { + viewUpdateEvent.view.first_contentful_paint = currentFCP + } + + const currentFID = toServerDuration(view.initialViewMetrics.firstInput?.delay) + if (currentFID !== prev.view.first_input_delay) { + viewUpdateEvent.view.first_input_delay = currentFID + viewUpdateEvent.view.first_input_time = toServerDuration(view.initialViewMetrics.firstInput?.time) + viewUpdateEvent.view.first_input_target_selector = view.initialViewMetrics.firstInput?.targetSelector + } + + const currentLCP = toServerDuration(view.initialViewMetrics.largestContentfulPaint?.value) + if (currentLCP !== prev.view.largest_contentful_paint) { + viewUpdateEvent.view.largest_contentful_paint = currentLCP + viewUpdateEvent.view.largest_contentful_paint_target_selector = + view.initialViewMetrics.largestContentfulPaint?.targetSelector + } + + const currentFirstByte = toServerDuration(view.initialViewMetrics.navigationTimings?.firstByte) + if (currentFirstByte !== prev.view.first_byte) { + viewUpdateEvent.view.first_byte = currentFirstByte + } + + const currentDomComplete = toServerDuration(view.initialViewMetrics.navigationTimings?.domComplete) + if (currentDomComplete !== prev.view.dom_complete) { + viewUpdateEvent.view.dom_complete = currentDomComplete + } + + const currentDomContentLoaded = toServerDuration(view.initialViewMetrics.navigationTimings?.domContentLoaded) + if (currentDomContentLoaded !== prev.view.dom_content_loaded) { + viewUpdateEvent.view.dom_content_loaded = currentDomContentLoaded + } + + const currentDomInteractive = toServerDuration(view.initialViewMetrics.navigationTimings?.domInteractive) + if (currentDomInteractive !== prev.view.dom_interactive) { + viewUpdateEvent.view.dom_interactive = currentDomInteractive + } + + const currentLoadEvent = toServerDuration(view.initialViewMetrics.navigationTimings?.loadEvent) + if (currentLoadEvent !== prev.view.load_event) { + viewUpdateEvent.view.load_event = currentLoadEvent + } + + // Scroll display — full replacement if any subfield changed. + // Convert maxScrollHeightTime to ServerDuration before comparison — Duration (ms) vs ServerDuration (ns) + // are not directly comparable; the snapshot stores ServerDuration from processViewUpdate. + const currentScroll = view.commonViewMetrics.scroll + const currentScrollNormalized = currentScroll + ? { + maxDepth: currentScroll.maxDepth, + maxDepthScrollTop: currentScroll.maxDepthScrollTop, + maxScrollHeight: currentScroll.maxScrollHeight, + maxScrollHeightTime: toServerDuration(currentScroll.maxScrollHeightTime), + } + : undefined + if (JSON.stringify(currentScrollNormalized) !== JSON.stringify(getPrevScroll(prev))) { + viewUpdateEvent.display = currentScrollNormalized + ? { + scroll: { + max_depth: currentScrollNormalized.maxDepth, + max_depth_scroll_top: currentScrollNormalized.maxDepthScrollTop, + max_scroll_height: currentScrollNormalized.maxScrollHeight, + max_scroll_height_time: currentScrollNormalized.maxScrollHeightTime, + }, + } + : undefined + } + + // Custom timings — REPLACE semantics: send full object if changed + const currentCustomTimings = !isEmptyObject(view.customTimings) + ? mapValues(view.customTimings, toServerDuration as (duration: Duration) => ServerDuration) + : undefined + if (JSON.stringify(currentCustomTimings) !== JSON.stringify(prev.view.custom_timings)) { + viewUpdateEvent.view.custom_timings = currentCustomTimings + } + + return { + rawRumEvent: viewUpdateEvent, + startClocks: view.startClocks, + duration: view.duration, + domainContext: { + location: view.location, + handlingStack: view.handlingStack, + }, + } +} + +function getPrevScroll(prev: RawRumViewEvent) { + if (!prev.display?.scroll) { + return undefined + } + // Reconstruct a comparable scroll object in the same shape as commonViewMetrics.scroll + const s = prev.display.scroll + return { + maxDepth: s.max_depth, + maxDepthScrollTop: s.max_depth_scroll_top, + maxScrollHeight: s.max_scroll_height, + maxScrollHeightTime: s.max_scroll_height_time, + } +} + function computeViewPerformanceData( { cumulativeLayoutShift, interactionToNextPaint }: CommonViewMetrics, { firstContentfulPaint, firstInput, largestContentfulPaint }: InitialViewMetrics diff --git a/packages/rum-core/src/domainContext.types.ts b/packages/rum-core/src/domainContext.types.ts index f4de716dcc..964bdfb842 100644 --- a/packages/rum-core/src/domainContext.types.ts +++ b/packages/rum-core/src/domainContext.types.ts @@ -6,21 +6,23 @@ 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 - | RumManualResourceEventDomainContext - : 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 + | RumManualResourceEventDomainContext + : 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 766cc33e94..d767bbfbcf 100644 --- a/packages/rum-core/src/rawRumEvent.types.ts +++ b/packages/rum-core/src/rawRumEvent.types.ts @@ -18,6 +18,7 @@ import type { RumLongTaskEvent, RumResourceEvent, RumViewEvent, + RumViewUpdateEvent, RumVitalEvent, } from './rumEvent.types' @@ -26,6 +27,7 @@ export const RumEventType = { ERROR: 'error', LONG_TASK: 'long_task', VIEW: 'view', + VIEW_UPDATE: 'view_update', RESOURCE: 'resource', VITAL: 'vital', } as const @@ -34,6 +36,7 @@ export type RumEventType = (typeof RumEventType)[keyof typeof RumEventType] export type AssembledRumEvent = ( | RumViewEvent + | RumViewUpdateEvent | RumActionEvent | RumResourceEvent | RumErrorEvent @@ -388,10 +391,58 @@ export const VitalType = { export type VitalType = (typeof VitalType)[keyof typeof VitalType] +export interface RawRumViewUpdateEvent { + date: TimeStamp + type: typeof RumEventType.VIEW_UPDATE + view: { + time_spent: ServerDuration + // is_active omitted: VUs are only emitted for active views; view-end sends a full VIEW + // Counters — included only when changed + error?: Count + action?: Count + long_task?: Count + resource?: Count + frustration?: Count + // Web vitals — included only when changed + loading_time?: ServerDuration + cumulative_layout_shift?: number + cumulative_layout_shift_time?: ServerDuration + cumulative_layout_shift_target_selector?: string + interaction_to_next_paint?: ServerDuration + interaction_to_next_paint_time?: ServerDuration + interaction_to_next_paint_target_selector?: string + // Initial metrics — set once + first_contentful_paint?: ServerDuration + first_input_delay?: ServerDuration + first_input_time?: ServerDuration + first_input_target_selector?: string + largest_contentful_paint?: ServerDuration + largest_contentful_paint_target_selector?: string + dom_complete?: ServerDuration + dom_content_loaded?: ServerDuration + dom_interactive?: ServerDuration + load_event?: ServerDuration + first_byte?: ServerDuration + // Performance data — included when changed + performance?: ViewPerformanceData + // Custom timings — full object when changed (REPLACE semantics) + custom_timings?: { [key: string]: ServerDuration } + } + display?: ViewDisplay + _dd: { + document_version: number + replay_stats?: ReplayStats + configuration?: { + start_session_replay_recording_manually: boolean + } + } +} + 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..93a597e8ee 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 @@ -1331,6 +1332,250 @@ export type RumViewEvent = CommonProperties & } [k: string]: unknown } +/** + * Schema of all properties of a View Update event (partial view update containing only changed fields) + */ +export type RumViewUpdateEvent = CommonProperties & + ViewContainerSchema & + StreamSchema & { + /** + * RUM event type + */ + readonly type: 'view_update' + /** + * View properties (only fields that changed since the last view event) + */ + 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 + /** + * 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 + */ + readonly largest_contentful_paint_target_selector?: string + /** + * @deprecated + * Duration in ns of the first input event delay (deprecated) + */ + readonly first_input_delay?: number + /** + * @deprecated + * Duration in ns to the first input (deprecated) + */ + readonly first_input_time?: number + /** + * @deprecated + * CSS selector path of the first input target element (deprecated) + */ + readonly first_input_target_selector?: string + /** + * @deprecated + * Longest duration in ns between an interaction and the next paint (deprecated) + */ + readonly interaction_to_next_paint?: number + /** + * @deprecated + * Duration in ns between start of the view and start of the INP (deprecated) + */ + readonly interaction_to_next_paint_time?: number + /** + * @deprecated + * CSS selector path of the interacted element corresponding to INP (deprecated) + */ + readonly interaction_to_next_paint_target_selector?: string + /** + * @deprecated + * Total layout shift score that occurred on the view (deprecated) + */ + 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) + */ + readonly cumulative_layout_shift_time?: number + /** + * @deprecated + * CSS selector path of the first element of the largest layout shift contributing to CLS (deprecated) + */ + 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 + */ + readonly custom_timings?: { + [k: string]: number + } + /** + * Whether the View corresponding to this event is considered active + */ + readonly is_active?: 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 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 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 + } + /** + * Performance data. (Web Vitals, etc.) + */ + performance?: ViewPerformanceData + [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 when the maximum scroll depth was reached (in pixels) + */ + readonly max_depth_scroll_top?: number + /** + * Maximum page scroll 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 (in nanoseconds) + */ + readonly max_scroll_height_time?: number + [k: string]: unknown + } + [k: string]: unknown + } + /** + * Internal properties + */ + readonly _dd: { + /** + * Version of the update of the view event + */ + readonly document_version: number + /** + * 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 + } + /** + * 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 + } + [k: string]: unknown + } + [k: string]: unknown + } export type RumVitalEvent = RumVitalDurationEvent | RumVitalOperationStepEvent /** * Schema for a duration vital event. diff --git a/packages/rum-core/src/transport/startRumBatch.spec.ts b/packages/rum-core/src/transport/startRumBatch.spec.ts new file mode 100644 index 0000000000..595140965e --- /dev/null +++ b/packages/rum-core/src/transport/startRumBatch.spec.ts @@ -0,0 +1,597 @@ +import type { PageMayExitEvent } from '@datadog/browser-core' +import { createIdentityEncoder, Observable } from '@datadog/browser-core' +import { interceptRequests } from '@datadog/browser-core/test' +import { mockRumConfiguration } from '../../test' +import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle' +import { RumEventType } from '../rawRumEvent.types' +import type { AssembledRumEvent } from '../rawRumEvent.types' +import { startRumBatch } from './startRumBatch' + +describe('startRumBatch', () => { + let lifeCycle: LifeCycle + let sessionExpireObservable: Observable + let interceptor: ReturnType + + beforeEach(() => { + lifeCycle = new LifeCycle() + sessionExpireObservable = new Observable() + interceptor = interceptRequests() + + startRumBatch( + mockRumConfiguration(), + lifeCycle, + () => undefined, + new Observable(), + sessionExpireObservable, + () => createIdentityEncoder() + ) + }) + + function flush() { + sessionExpireObservable.notify() + } + + it('should route view_update events to batch.add()', () => { + const viewUpdateEvent = { + type: RumEventType.VIEW_UPDATE, + view: { id: 'test-view', time_spent: 0, is_active: true }, + _dd: { document_version: 2 }, + } as unknown as AssembledRumEvent + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, viewUpdateEvent) + flush() + + expect(interceptor.requests.length).toBe(1) + const payload = interceptor.requests[0].body + // view_update events go through add() — written to the encoder immediately, + // NOT to the upsert buffer. Verify the payload contains our event. + expect(payload).toContain('"type":"view_update"') + }) + + it('should route view events to batch.upsert()', () => { + // Send two view events for the same view — upsert should keep only the latest + const viewEvent1 = { + type: RumEventType.VIEW, + view: { id: 'test-view', time_spent: 100, is_active: true }, + } as unknown as AssembledRumEvent + + const viewEvent2 = { + type: RumEventType.VIEW, + view: { id: 'test-view', time_spent: 200, is_active: true }, + } as unknown as AssembledRumEvent + + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, viewEvent1) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, viewEvent2) + flush() + + expect(interceptor.requests.length).toBe(1) + const payload = interceptor.requests[0].body + // upsert deduplicates by view.id — only the latest should be present + expect(payload).toContain('"time_spent":200') + expect(payload).not.toContain('"time_spent":100') + }) + + it('should preserve all view_update events without deduplication', () => { + const updates = [1, 2, 3].map( + (version) => + ({ + type: RumEventType.VIEW_UPDATE, + view: { id: 'same-view', time_spent: version * 100, is_active: true }, + _dd: { document_version: version }, + }) as unknown as AssembledRumEvent + ) + + updates.forEach((event) => lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, event)) + flush() + + expect(interceptor.requests.length).toBe(1) + const payload = interceptor.requests[0].body + // All three view_update events should be present (add() preserves each one) + expect(payload).toContain('"document_version":1') + expect(payload).toContain('"document_version":2') + expect(payload).toContain('"document_version":3') + }) + + describe('post-assembly strip', () => { + const DD_CONFIG = { session_sample_rate: 100, session_replay_sample_rate: 0, format_version: 2 } + + function makeViewEvent(overrides: Record = {}): AssembledRumEvent { + return { + type: RumEventType.VIEW, + view: { + id: 'view-1', + time_spent: 100, + is_active: true, + url: 'https://example.com', + referrer: '', + name: 'home', + }, + application: { id: 'app-1' }, + session: { id: 'session-1', type: 'user', sampled_for_replay: false }, + date: 1000, + _dd: { document_version: 1, format_version: 2, sdk_name: 'rum', configuration: DD_CONFIG }, + context: { foo: 'bar' }, + connectivity: { status: 'connected', interfaces: ['wifi'] }, + usr: { id: 'user-1', name: 'Alice' }, + account: { id: 'acct-1', name: 'Acme' }, + service: 'my-service', + version: '1.0.0', + source: 'browser', + display: { viewport: { width: 1280, height: 800 } }, + ddtags: 'env:staging,service:my-service', + ...overrides, + } as unknown as AssembledRumEvent + } + + function makeViewUpdateEvent(overrides: Record = {}): AssembledRumEvent { + return { + type: RumEventType.VIEW_UPDATE, + view: { + id: 'view-1', + time_spent: 200, + is_active: true, + url: 'https://example.com', + referrer: '', + name: 'home', + }, + application: { id: 'app-1' }, + session: { id: 'session-1', type: 'user', sampled_for_replay: false }, + date: 2000, + _dd: { document_version: 2, format_version: 2, sdk_name: 'rum', configuration: DD_CONFIG }, + context: { foo: 'bar' }, + connectivity: { status: 'connected', interfaces: ['wifi'] }, + usr: { id: 'user-1', name: 'Alice' }, + account: { id: 'acct-1', name: 'Acme' }, + service: 'my-service', + version: '1.0.0', + source: 'browser', + display: { viewport: { width: 1280, height: 800 } }, + ddtags: 'env:staging,service:my-service', + ...overrides, + } as unknown as AssembledRumEvent + } + + it('post-assembly strip removes unchanged display.viewport from view_update', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent()) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const viewUpdateLine = lines.find((l) => l.includes('"view_update"'))! + const viewUpdate = JSON.parse(viewUpdateLine) + expect(viewUpdate.display).toBeUndefined() + }) + + it('post-assembly strip keeps changed display.viewport in view_update', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify( + LifeCycleEventType.RUM_EVENT_COLLECTED, + makeViewUpdateEvent({ display: { viewport: { width: 1024, height: 600 } } }) + ) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const viewUpdateLine = lines.find((l) => l.includes('"view_update"'))! + const viewUpdate = JSON.parse(viewUpdateLine) + expect(viewUpdate.display?.viewport).toEqual({ width: 1024, height: 600 }) + }) + + it('post-assembly strip removes viewport but keeps scroll when scroll is present', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify( + LifeCycleEventType.RUM_EVENT_COLLECTED, + makeViewUpdateEvent({ display: { viewport: { width: 1280, height: 800 }, scroll: { max_depth: 500 } } }) + ) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const viewUpdateLine = lines.find((l) => l.includes('"view_update"'))! + const viewUpdate = JSON.parse(viewUpdateLine) + expect(viewUpdate.display?.viewport).toBeUndefined() + expect(viewUpdate.display?.scroll).toEqual({ max_depth: 500 }) + }) + + it('post-assembly strip removes unchanged context from view_update', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent()) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + // First line is the VIEW event (upserted), second is VIEW_UPDATE (added) + const viewUpdateLine = lines.find((l) => l.includes('"view_update"'))! + const viewUpdate = JSON.parse(viewUpdateLine) + expect(viewUpdate.context).toBeUndefined() + }) + + it('post-assembly strip removes unchanged view.name from view_update', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent()) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const viewUpdateLine = lines.find((l) => l.includes('"view_update"'))! + const viewUpdate = JSON.parse(viewUpdateLine) + expect(viewUpdate.view.name).toBeUndefined() + }) + + it('post-assembly strip keeps changed view.name in view_update', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify( + LifeCycleEventType.RUM_EVENT_COLLECTED, + makeViewUpdateEvent({ + view: { + id: 'view-1', + time_spent: 200, + is_active: true, + url: 'https://example.com', + referrer: '', + name: 'new-page', + }, + }) + ) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const viewUpdateLine = lines.find((l) => l.includes('"view_update"'))! + const viewUpdate = JSON.parse(viewUpdateLine) + expect(viewUpdate.view.name).toBe('new-page') + }) + + it('post-assembly strip removes _dd.configuration from view_update', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent()) + flush() + + const payload = interceptor.requests[0].body + const viewUpdate = JSON.parse( + payload + .trim() + .split('\n') + .find((l) => l.includes('"view_update"'))! + ) + expect(viewUpdate._dd.configuration).toBeUndefined() + expect(viewUpdate._dd.document_version).toBe(2) // required field kept + }) + + it('post-assembly strip removes _dd.format_version and _dd.sdk_name from view_update', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent()) + flush() + + const payload = interceptor.requests[0].body + const viewUpdate = JSON.parse( + payload + .trim() + .split('\n') + .find((l) => l.includes('"view_update"'))! + ) + expect(viewUpdate._dd.format_version).toBeUndefined() + expect(viewUpdate._dd.sdk_name).toBeUndefined() + }) + + it('post-assembly strip removes session.type from view_update', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent()) + flush() + + const payload = interceptor.requests[0].body + const viewUpdate = JSON.parse( + payload + .trim() + .split('\n') + .find((l) => l.includes('"view_update"'))! + ) + expect(viewUpdate.session.type).toBeUndefined() + expect(viewUpdate.session.id).toBe('session-1') // required field kept + }) + + it('post-assembly strip removes ddtags from view_update', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent()) + flush() + + const payload = interceptor.requests[0].body + const viewUpdate = JSON.parse( + payload + .trim() + .split('\n') + .find((l) => l.includes('"view_update"'))! + ) + expect(viewUpdate.ddtags).toBeUndefined() + }) + + it('post-assembly strip removes viewport on second view_update when unchanged', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + // First view_update: viewport sent (new info, snapshot has it from VIEW event) + lifeCycle.notify( + LifeCycleEventType.RUM_EVENT_COLLECTED, + makeViewUpdateEvent({ + _dd: { document_version: 2, format_version: 2, sdk_name: 'rum', configuration: DD_CONFIG }, + }) + ) + // Second view_update: same viewport — should be stripped now + lifeCycle.notify( + LifeCycleEventType.RUM_EVENT_COLLECTED, + makeViewUpdateEvent({ + _dd: { document_version: 3, format_version: 2, sdk_name: 'rum', configuration: DD_CONFIG }, + date: 3000, + }) + ) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const updates = lines.filter((l) => l.includes('"view_update"')).map((l) => JSON.parse(l) as Record) + expect(updates).toHaveSize(2) + // doc_v=2: snapshot has viewport (from VIEW), so viewport stripped + expect(updates[0]._dd.document_version).toBe(2) + expect(updates[0].display?.viewport).toBeUndefined() + // doc_v=3: viewport still stripped (unchanged) + expect(updates[1]._dd.document_version).toBe(3) + expect(updates[1].display?.viewport).toBeUndefined() + }) + + it('post-assembly strip keeps changed context in view_update', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent({ context: { foo: 'changed' } })) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const viewUpdateLine = lines.find((l) => l.includes('"view_update"'))! + const viewUpdate = JSON.parse(viewUpdateLine) + expect(viewUpdate.context).toEqual({ foo: 'changed' }) + }) + + it('post-assembly strip removes unchanged connectivity', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent()) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const viewUpdateLine = lines.find((l) => l.includes('"view_update"'))! + const viewUpdate = JSON.parse(viewUpdateLine) + expect(viewUpdate.connectivity).toBeUndefined() + }) + + it('post-assembly strip keeps changed usr', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify( + LifeCycleEventType.RUM_EVENT_COLLECTED, + makeViewUpdateEvent({ usr: { id: 'user-2', name: 'Bob' } }) + ) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const viewUpdateLine = lines.find((l) => l.includes('"view_update"'))! + const viewUpdate = JSON.parse(viewUpdateLine) + expect(viewUpdate.usr).toEqual({ id: 'user-2', name: 'Bob' }) + }) + + it('post-assembly strip always preserves required fields (application.id, session.id, view.id, date, type, _dd.document_version)', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent()) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const viewUpdateLine = lines.find((l) => l.includes('"view_update"'))! + const viewUpdate = JSON.parse(viewUpdateLine) + + expect(viewUpdate.type).toBe('view_update') + expect(viewUpdate.date).toBe(2000) + expect(viewUpdate.application.id).toBe('app-1') + expect(viewUpdate.session.id).toBe('session-1') + expect(viewUpdate.view.id).toBe('view-1') + expect(viewUpdate._dd.document_version).toBe(2) + }) + + it('post-assembly strip does not apply when no snapshot exists', () => { + // Send view_update WITHOUT a prior VIEW event + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent()) + flush() + + const payload = interceptor.requests[0].body + const viewUpdate = JSON.parse(payload.trim()) + // All fields should be present since no snapshot to compare against + expect(viewUpdate.context).toEqual({ foo: 'bar' }) + expect(viewUpdate.connectivity).toBeDefined() + expect(viewUpdate.usr).toBeDefined() + }) + + it('snapshot is stored when VIEW event is collected', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + // Send view_update — if snapshot was stored, context will be stripped + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent()) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const viewUpdateLine = lines.find((l) => l.includes('"view_update"'))! + const viewUpdate = JSON.parse(viewUpdateLine) + // context stripped → snapshot was stored + expect(viewUpdate.context).toBeUndefined() + }) + + it('snapshot backfills usr from first view_update so subsequent ones can strip it', () => { + // Baseline VIEW has no usr (set after init) + const baseView = makeViewEvent({ usr: undefined }) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, baseView) + + // First VU brings usr — should be sent (new info vs snapshot) + const vu1 = makeViewUpdateEvent({ _dd: { document_version: 2 }, usr: { id: 'user-1', name: 'Alice' } }) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, vu1) + + // Second VU same usr — should be stripped (snapshot now has it) + const vu2 = makeViewUpdateEvent({ _dd: { document_version: 3 }, usr: { id: 'user-1', name: 'Alice' } }) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, vu2) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const vu1Line = lines.find((l) => l.includes('"document_version":2'))! + const vu2Line = lines.find((l) => l.includes('"document_version":3'))! + // First VU: usr present (new info) + expect(JSON.parse(vu1Line).usr).toEqual({ id: 'user-1', name: 'Alice' }) + // Second VU: usr stripped (unchanged vs backfilled snapshot) + expect(JSON.parse(vu2Line).usr).toBeUndefined() + }) + + it('strip removes unchanged feature_flags from view_update', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent({ feature_flags: { my_flag: true } })) + lifeCycle.notify( + LifeCycleEventType.RUM_EVENT_COLLECTED, + makeViewUpdateEvent({ feature_flags: { my_flag: true } }) + ) + flush() + + const payload = interceptor.requests[0].body + const viewUpdate = JSON.parse( + payload + .trim() + .split('\n') + .find((l) => l.includes('"view_update"'))! + ) + expect(viewUpdate.feature_flags).toBeUndefined() + }) + + it('strip keeps changed feature_flags in view_update', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent({ feature_flags: { my_flag: true } })) + lifeCycle.notify( + LifeCycleEventType.RUM_EVENT_COLLECTED, + makeViewUpdateEvent({ feature_flags: { my_flag: true, new_flag: false } }) + ) + flush() + + const payload = interceptor.requests[0].body + const viewUpdate = JSON.parse( + payload + .trim() + .split('\n') + .find((l) => l.includes('"view_update"'))! + ) + expect(viewUpdate.feature_flags).toEqual({ my_flag: true, new_flag: false }) + }) + + it('snapshot backfills feature_flags from first view_update so subsequent ones can strip it', () => { + // Baseline VIEW has no feature_flags (flags evaluated after init) + const baseView = makeViewEvent({ feature_flags: undefined }) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, baseView) + + // First VU brings feature_flags — should be sent (new info vs snapshot) + const vu1 = makeViewUpdateEvent({ _dd: { document_version: 2 }, feature_flags: { my_flag: true } }) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, vu1) + + // Second VU same feature_flags — should be stripped (snapshot now has it) + const vu2 = makeViewUpdateEvent({ _dd: { document_version: 3 }, feature_flags: { my_flag: true } }) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, vu2) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const vu1Line = lines.find((l) => l.includes('"document_version":2'))! + const vu2Line = lines.find((l) => l.includes('"document_version":3'))! + expect(JSON.parse(vu1Line).feature_flags).toEqual({ my_flag: true }) + expect(JSON.parse(vu2Line).feature_flags).toBeUndefined() + }) + + it('strip removes _dd.browser_sdk_version from view_update', () => { + lifeCycle.notify( + LifeCycleEventType.RUM_EVENT_COLLECTED, + makeViewEvent({ + _dd: { + document_version: 1, + format_version: 2, + sdk_name: 'rum', + configuration: DD_CONFIG, + browser_sdk_version: '5.0.0', + }, + }) + ) + lifeCycle.notify( + LifeCycleEventType.RUM_EVENT_COLLECTED, + makeViewUpdateEvent({ + _dd: { + document_version: 2, + format_version: 2, + sdk_name: 'rum', + configuration: DD_CONFIG, + browser_sdk_version: '5.0.0', + }, + }) + ) + flush() + + const payload = interceptor.requests[0].body + const viewUpdate = JSON.parse( + payload + .trim() + .split('\n') + .find((l) => l.includes('"view_update"'))! + ) + expect(viewUpdate._dd.browser_sdk_version).toBeUndefined() + expect(viewUpdate._dd.document_version).toBe(2) + }) + + it('strip removes unchanged synthetics from view_update', () => { + const synthetics = { test_id: 'test-1', result_id: 'result-1', injected: true } + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent({ synthetics })) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent({ synthetics })) + flush() + + const payload = interceptor.requests[0].body + const viewUpdate = JSON.parse( + payload + .trim() + .split('\n') + .find((l) => l.includes('"view_update"'))! + ) + expect(viewUpdate.synthetics).toBeUndefined() + }) + + it('strip removes unchanged ci_test from view_update', () => { + const ciTest = { test_execution_id: 'exec-1' } + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent({ ci_test: ciTest })) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent({ ci_test: ciTest })) + flush() + + const payload = interceptor.requests[0].body + const viewUpdate = JSON.parse( + payload + .trim() + .split('\n') + .find((l) => l.includes('"view_update"'))! + ) + expect(viewUpdate.ci_test).toBeUndefined() + }) + + it('snapshot is deleted on view end (is_active=false)', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewEvent()) + // End the view + lifeCycle.notify( + LifeCycleEventType.RUM_EVENT_COLLECTED, + makeViewEvent({ + view: { id: 'view-1', time_spent: 500, is_active: false, url: 'https://example.com', referrer: '' }, + }) + ) + // Send view_update after view end — snapshot should be gone, no stripping + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, makeViewUpdateEvent()) + flush() + + const payload = interceptor.requests[0].body + const lines = payload.trim().split('\n') + const viewUpdateLine = lines.find((l) => l.includes('"view_update"'))! + const viewUpdate = JSON.parse(viewUpdateLine) + // context NOT stripped → snapshot was deleted + expect(viewUpdate.context).toEqual({ foo: 'bar' }) + }) + }) +}) diff --git a/packages/rum-core/src/transport/startRumBatch.ts b/packages/rum-core/src/transport/startRumBatch.ts index d4e6b31920..d1b49700ac 100644 --- a/packages/rum-core/src/transport/startRumBatch.ts +++ b/packages/rum-core/src/transport/startRumBatch.ts @@ -1,5 +1,13 @@ import type { Observable, RawError, PageMayExitEvent, Encoder } from '@datadog/browser-core' -import { createBatch, createFlushController, createHttpRequest, DeflateEncoderStreamId } from '@datadog/browser-core' +import { + createBatch, + createFlushController, + createHttpRequest, + DeflateEncoderStreamId, + display, + ExperimentalFeature, + isExperimentalFeatureEnabled, +} from '@datadog/browser-core' import type { RumConfiguration } from '../domain/configuration' import type { LifeCycle } from '../domain/lifeCycle' import { LifeCycleEventType } from '../domain/lifeCycle' @@ -28,9 +36,52 @@ export function startRumBatch( }), }) + // Store last assembled VIEW event per view_id for post-assembly strip + const assembledViewSnapshots = new Map() + lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (serverRumEvent: AssembledRumEvent) => { if (serverRumEvent.type === RumEventType.VIEW) { + // Store snapshot for future view_update strip + assembledViewSnapshots.set(serverRumEvent.view.id, serverRumEvent) batch.upsert(serverRumEvent, serverRumEvent.view.id) + + // Cleanup on view end + if (!serverRumEvent.view.is_active) { + assembledViewSnapshots.delete(serverRumEvent.view.id) + } + } else if (serverRumEvent.type === RumEventType.VIEW_UPDATE) { + const snapshot = assembledViewSnapshots.get(serverRumEvent.view.id) + const stripped = snapshot ? stripUnchangedFields(serverRumEvent, snapshot) : serverRumEvent + // After stripping, backfill snapshot fields that may have been absent on the baseline VIEW + // but are now present on this view_update (e.g. usr set after init, viewport deferred via RAF). + // This ensures subsequent view_updates can strip them when unchanged. + if (snapshot) { + const snap = snapshot as any + const update = serverRumEvent as any + // Backfill any top-level field from this VU into the snapshot so subsequent VUs can strip + // it when unchanged. Covers fields absent from baseline VIEW (usr/feature_flags set after + // init) and any future new top-level fields automatically. + // Routing keys are always on the snapshot; display needs sub-field merge (see below). + for (const key of Object.keys(update)) { + if (VIEW_UPDATE_ROUTING_KEYS.has(key) || key === 'display') { + continue + } + if (update[key] !== undefined) { + snap[key] = update[key] + } + } + // display.viewport: deferred via RAF, merge into snapshot without overwriting scroll + const updateViewport = update.display?.viewport + if (updateViewport) { + snap.display = { ...snap.display, viewport: updateViewport } + } + } + + if (snapshot && isExperimentalFeatureEnabled(ExperimentalFeature.VIEW_UPDATE_CHAOS)) { + logStripDiagnostics(serverRumEvent, stripped, snapshot) + } + + batch.add(stripped) } else { batch.add(serverRumEvent) } @@ -38,3 +89,138 @@ export function startRumBatch( return batch } + +// Top-level keys that must always be present on a view_update for routing and identity. +// Everything else is stripped if equal to the VIEW snapshot (generic approach — new fields +// added to the schema are handled automatically without any code changes here). +const VIEW_UPDATE_ROUTING_KEYS = new Set(['type', 'application', 'date', 'view', 'session', '_dd']) + +function stripUnchangedFields( + viewUpdate: AssembledRumEvent & { type: typeof RumEventType.VIEW_UPDATE }, + snapshot: AssembledRumEvent +): AssembledRumEvent { + const result = { ...viewUpdate } as any + const snap = snapshot as any + const update = viewUpdate as any + + // Strip any top-level field not needed for routing/identity if it equals the snapshot. + // Covers all current session-static fields (usr, context, connectivity, feature_flags, + // service, version, source, synthetics, ci_test, ddtags, ...) and future ones automatically. + for (const key of Object.keys(result)) { + if (VIEW_UPDATE_ROUTING_KEYS.has(key)) { + continue + } + if (JSON.stringify(update[key]) === JSON.stringify(snap[key])) { + delete result[key] + } + } + + // display.viewport: strip when unchanged even if display.scroll is also present. + // The generic loop above can't handle this sub-field case: when scroll is present the full + // display object differs from the snapshot (which only has viewport), so nothing is stripped + // and the unchanged viewport leaks. Explicit sub-field strip after the generic pass fixes it. + if ( + result.display?.viewport && + snap.display?.viewport && + JSON.stringify(result.display.viewport) === JSON.stringify(snap.display.viewport) + ) { + delete result.display.viewport + if (Object.keys(result.display).length === 0) { + result.display = undefined + } + } + + // view.name/url/referrer: sub-fields of the routing view object, strip when unchanged. + if (update.view?.name === snap.view?.name) { + delete result.view.name + } + if (update.view?.url === snap.view?.url && update.view?.referrer === snap.view?.referrer) { + delete result.view.url + delete result.view.referrer + } + + // _dd: strip static per-session sub-fields; keep document_version (ordering) and drift (per-event). + if (result._dd) { + delete result._dd.format_version + delete result._dd.sdk_name + delete result._dd.configuration + delete result._dd.browser_sdk_version + } + + // session.type: always "user" (or "synthetics") for the entire session. + // Keep session.id (routing), sampled_for_replay and has_replay (can change mid-session). + if (result.session?.type !== undefined) { + delete result.session.type + } + + return result as AssembledRumEvent +} + +function logStripDiagnostics(original: AssembledRumEvent, stripped: AssembledRumEvent, snapshot: AssembledRumEvent) { + const orig = original as any + const strip = stripped as any + const snap = snapshot as any + + const strippedFields: string[] = [] + const keptChanged: string[] = [] + let bytesSaved = 0 + + // Top-level fields present in original but absent in stripped + for (const field of Object.keys(orig)) { + if (!(field in strip)) { + const size = JSON.stringify(orig[field]).length + bytesSaved += size + field.length + 3 + strippedFields.push(`${field}(${size}B)`) + } + } + + // _dd sub-fields that were stripped + const ddStripped = ['format_version', 'sdk_name', 'configuration', 'browser_sdk_version'] + for (const f of ddStripped) { + if (orig._dd?.[f] !== undefined && strip._dd?.[f] === undefined) { + const size = JSON.stringify(orig._dd[f]).length + bytesSaved += size + f.length + 3 + strippedFields.push(`_dd.${f}(${size}B)`) + } + } + + // session.type + if (orig.session?.type !== undefined && strip.session?.type === undefined) { + const size = JSON.stringify(orig.session.type).length + bytesSaved += size + 'type'.length + 3 + strippedFields.push(`session.type(${size}B)`) + } + + // view sub-fields: name, url, referrer + for (const f of ['name', 'url', 'referrer']) { + if (orig.view?.[f] !== undefined && strip.view?.[f] === undefined) { + const size = JSON.stringify(orig.view[f]).length + bytesSaved += size + f.length + 3 + strippedFields.push(`view.${f}(${size}B)`) + } + } + + // display.viewport + if (orig.display?.viewport !== undefined && strip.display?.viewport === undefined) { + const size = JSON.stringify(orig.display.viewport).length + bytesSaved += size + 'viewport'.length + 3 + strippedFields.push(`display.viewport(${size}B)`) + } + + // Non-routing fields still present in stripped — kept because they changed vs snapshot + for (const key of Object.keys(strip)) { + if (VIEW_UPDATE_ROUTING_KEYS.has(key)) { + continue + } + if (JSON.stringify(strip[key]) !== JSON.stringify(snap[key])) { + keptChanged.push(key) + } + } + + const docVersion = strip._dd?.document_version ?? '?' + const viewId = String(strip.view?.id ?? '?').slice(0, 8) + + display.debug( + `[VU Strip] v=${docVersion} view=${viewId} | stripped: [${strippedFields.join(', ')}] (~${bytesSaved}B saved) | kept(changed): [${keptChanged.join(', ') || 'none'}]` + ) +} diff --git a/packages/rum-core/test/fixtures.ts b/packages/rum-core/test/fixtures.ts index d5c15aceff..a76f764103 100644 --- a/packages/rum-core/test/fixtures.ts +++ b/packages/rum-core/test/fixtures.ts @@ -119,6 +119,21 @@ export function createRawRumEvent(type: RumEventType, overrides?: Context): RawR }, overrides ) + case RumEventType.VIEW_UPDATE: + return combine( + { + type, + _dd: { + document_version: 2, + }, + date: 0 as TimeStamp, + view: { + time_spent: 0 as ServerDuration, + is_active: true, + }, + }, + overrides + ) } } diff --git a/rum-events-format b/rum-events-format index 8dc61166ee..12b6a677a1 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit 8dc61166ee818608892d13b6565ff04a3f2a7fe9 +Subproject commit 12b6a677a14da8c34b1f7b192540658fea8fe8ef