diff --git a/packages/browser-core/src/tools/experimentalFeatures.ts b/packages/browser-core/src/tools/experimentalFeatures.ts index 65c39b5ffa..c46658bd6b 100644 --- a/packages/browser-core/src/tools/experimentalFeatures.ts +++ b/packages/browser-core/src/tools/experimentalFeatures.ts @@ -15,7 +15,6 @@ import { objectHasValue } from './utils/objectUtils' // eslint-disable-next-line no-restricted-syntax export enum ExperimentalFeature { TRACK_INTAKE_REQUESTS = 'track_intake_requests', - PARTIAL_VIEW_UPDATES = 'partial_view_updates', } const enabledExperimentalFeatures: Set = new Set() diff --git a/packages/browser-rum-core/src/domain/configuration/configuration.spec.ts b/packages/browser-rum-core/src/domain/configuration/configuration.spec.ts index aaf057d736..ba3ea8ef7c 100644 --- a/packages/browser-rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/browser-rum-core/src/domain/configuration/configuration.spec.ts @@ -832,6 +832,7 @@ describe('serializeRumConfiguration', () => { profilingSampleRate: 42, propagateTraceBaggage: true, trackResourceHeaders: true, + betaEnableViewUpdates: true, } type MapRumInitConfigurationKey = Key extends keyof InitConfiguration @@ -887,6 +888,7 @@ describe('serializeRumConfiguration', () => { use_remote_configuration_proxy: true, profiling_sample_rate: 42, track_resource_headers: 'default_headers', + beta_enable_view_updates: true, }) }) }) diff --git a/packages/browser-rum-core/src/domain/configuration/configuration.ts b/packages/browser-rum-core/src/domain/configuration/configuration.ts index 2c576a806a..25ef79e27a 100644 --- a/packages/browser-rum-core/src/domain/configuration/configuration.ts +++ b/packages/browser-rum-core/src/domain/configuration/configuration.ts @@ -343,6 +343,14 @@ export interface RumInitConfiguration extends InitConfiguration { * @category Data Collection */ allowedGraphQlUrls?: Array | undefined + + /** + * Enable partial view updates, which reduces bandwidth by sending only changed + * fields instead of full view events on intermediate updates. + * + * @category Beta + */ + betaEnableViewUpdates?: boolean | undefined } export type HybridInitConfiguration = Omit @@ -386,6 +394,7 @@ export interface RumConfiguration extends Configuration { sessionReplaySampleRate: number startSessionReplayRecordingManually: boolean trackUserInteractions: boolean + betaEnableViewUpdates: boolean trackViewsManually: boolean trackResources: boolean trackResourceHeaders: MatchHeader[] @@ -458,6 +467,7 @@ export function validateAndBuildRumConfiguration( workerUrl: initConfiguration.workerUrl, compressIntakeRequests: !!initConfiguration.compressIntakeRequests, trackUserInteractions: !!(initConfiguration.trackUserInteractions ?? true), + betaEnableViewUpdates: !!initConfiguration.betaEnableViewUpdates, trackViewsManually: !!initConfiguration.trackViewsManually, trackResources: !!(initConfiguration.trackResources ?? true), trackResourceHeaders: validateAndBuildTrackResourceHeaders(initConfiguration), @@ -687,6 +697,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) { profiling_sample_rate: configuration.profilingSampleRate, use_remote_configuration_proxy: !!configuration.remoteConfigurationProxy, track_resource_headers: getTrackResourceHeadersTelemetryValue(configuration.trackResourceHeaders), + beta_enable_view_updates: configuration.betaEnableViewUpdates, ...baseSerializedConfiguration, } satisfies RawTelemetryConfiguration } diff --git a/packages/browser-rum-core/src/transport/startRumBatch.spec.ts b/packages/browser-rum-core/src/transport/startRumBatch.spec.ts index e1783dd747..13389e6b38 100644 --- a/packages/browser-rum-core/src/transport/startRumBatch.spec.ts +++ b/packages/browser-rum-core/src/transport/startRumBatch.spec.ts @@ -1,7 +1,6 @@ -import { ExperimentalFeature, Observable, addExperimentalFeatures } from '@datadog/browser-core' -import { resetExperimentalFeatures } from '@datadog/browser-core/src/tools/experimentalFeatures' +import { Observable } from '@datadog/browser-core' import type { FlushEvent } from '@datadog/browser-core/src/transport/flushController' -import { registerCleanupTask } from '@datadog/browser-core/test' + import type { AssembledRumEvent } from '../rawRumEvent.types' import { RumEventType } from '../rawRumEvent.types' import type { RumViewEvent } from '../rumEvent.types' @@ -215,11 +214,6 @@ describe('computeAssembledViewDiff', () => { }) describe('startRumBatch partial_view_updates routing', () => { - beforeEach(() => { - addExperimentalFeatures([ExperimentalFeature.PARTIAL_VIEW_UPDATES]) - registerCleanupTask(resetExperimentalFeatures) - }) - it('PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL should be 100', () => { expect(PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL).toBe(100) }) @@ -288,13 +282,10 @@ function makeView(viewId: string, docVersion: number, overrides: Record { - describe('feature flag OFF', () => { + describe('feature disabled', () => { it('should upsert full VIEW events (legacy behaviour)', () => { - resetExperimentalFeatures() - registerCleanupTask(resetExperimentalFeatures) - const { batch, upsertSpy } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, false) const v1 = makeView('view-1', 1) const v2 = makeView('view-1', 2) @@ -308,14 +299,9 @@ describe('createBatchDispatcher', () => { }) describe('non-view events', () => { - beforeEach(() => { - addExperimentalFeatures([ExperimentalFeature.PARTIAL_VIEW_UPDATES]) - registerCleanupTask(resetExperimentalFeatures) - }) - it('should always append non-view events', () => { const { batch, addSpy, upsertSpy } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, true) const action = { type: RumEventType.ACTION } as unknown as AssembledRumEvent dispatch(action) @@ -326,14 +312,9 @@ describe('createBatchDispatcher', () => { }) describe('optimization 1 — VIEW already in batch', () => { - beforeEach(() => { - addExperimentalFeatures([ExperimentalFeature.PARTIAL_VIEW_UPDATES]) - registerCleanupTask(resetExperimentalFeatures) - }) - it('should upsert the latest full VIEW for every intermediate update', () => { const { batch, upsertSpy } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, true) const v1 = makeView('view-1', 1) const v2 = makeView('view-1', 2, { @@ -363,7 +344,7 @@ describe('createBatchDispatcher', () => { it('should not emit any view_update events while the VIEW is in the batch', () => { const { batch, upsertSpy } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, true) dispatch(makeView('view-1', 1)) dispatch(makeView('view-1', 2)) @@ -375,14 +356,9 @@ describe('createBatchDispatcher', () => { }) describe('optimization 2 — no VIEW in batch (post-flush)', () => { - beforeEach(() => { - addExperimentalFeatures([ExperimentalFeature.PARTIAL_VIEW_UPDATES]) - registerCleanupTask(resetExperimentalFeatures) - }) - it('should upsert an aggregate view_update after a flush', () => { const { batch, upsertSpy, flush } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, true) dispatch(makeView('view-1', 1)) flush() // batchHasFullView resets; batchBase = v1 @@ -412,7 +388,7 @@ describe('createBatchDispatcher', () => { it('should aggregate multiple updates into a single view_update per batch', () => { const { batch, upsertSpy, flush } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, true) dispatch(makeView('view-1', 1)) flush() @@ -449,7 +425,7 @@ describe('createBatchDispatcher', () => { it('should compute the diff from batchBase, not from the previous intermediate update', () => { const { batch, upsertSpy, flush } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, true) // Initial view with action.count = 0 dispatch(makeView('view-1', 1)) @@ -496,7 +472,7 @@ describe('createBatchDispatcher', () => { it('should emit nothing if nothing changed since batchBase', () => { const { batch, upsertSpy, flush } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, true) const v1 = makeView('view-1', 1) dispatch(v1) @@ -513,14 +489,9 @@ describe('createBatchDispatcher', () => { }) describe('checkpoint', () => { - beforeEach(() => { - addExperimentalFeatures([ExperimentalFeature.PARTIAL_VIEW_UPDATES]) - registerCleanupTask(resetExperimentalFeatures) - }) - it('should send a full VIEW after PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL intermediate updates', () => { const { batch, upsertSpy, flush } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, true) dispatch(makeView('view-1', 1)) flush() @@ -553,14 +524,9 @@ describe('createBatchDispatcher', () => { }) describe('view lifecycle', () => { - beforeEach(() => { - addExperimentalFeatures([ExperimentalFeature.PARTIAL_VIEW_UPDATES]) - registerCleanupTask(resetExperimentalFeatures) - }) - it('should send a full VIEW when a new view starts', () => { const { batch, upsertSpy } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, true) dispatch(makeView('view-1', 1)) dispatch(makeView('view-2', 1)) // new view @@ -574,7 +540,7 @@ describe('createBatchDispatcher', () => { it('should send a full VIEW when the view ends', () => { const { batch, upsertSpy, flush } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, true) dispatch(makeView('view-1', 1)) flush() @@ -603,7 +569,7 @@ describe('createBatchDispatcher', () => { it('should upsert a stale view-end without resetting state for the current view', () => { const { batch, upsertSpy, flush } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, true) // view-1 starts and flushes dispatch(makeView('view-1', 1)) @@ -655,7 +621,7 @@ describe('createBatchDispatcher', () => { it('should reset to opt-1 after a new view starts following a flush', () => { const { batch, upsertSpy, flush } = createMockBatch() - const { dispatch } = createBatchDispatcher(batch) + const { dispatch } = createBatchDispatcher(batch, true) dispatch(makeView('view-1', 1)) flush() diff --git a/packages/browser-rum-core/src/transport/startRumBatch.ts b/packages/browser-rum-core/src/transport/startRumBatch.ts index 7831424c71..c6096d8e4e 100644 --- a/packages/browser-rum-core/src/transport/startRumBatch.ts +++ b/packages/browser-rum-core/src/transport/startRumBatch.ts @@ -1,11 +1,5 @@ import type { Observable, Encoder, Context } from '@datadog/browser-core' -import { - createBatch, - DeflateEncoderStreamId, - isExperimentalFeatureEnabled, - ExperimentalFeature, - sendToExtension, -} from '@datadog/browser-core' +import { createBatch, DeflateEncoderStreamId, sendToExtension } from '@datadog/browser-core' import { combine } from '@datadog/js-core/util' import { createEndpointBuilder, createReplicaEndpointBuilder } from '@datadog/js-core/transport' import type { RumConfiguration } from '../domain/configuration' @@ -68,7 +62,8 @@ export function computeAssembledViewDiff(current: RumViewEvent, last: RumViewEve * aggregated into a single view_update diff against the last flushed state. */ export function createBatchDispatcher( - batch: Pick, 'flushObservable' | 'isEmpty' | 'add' | 'upsert'> + batch: Pick, 'flushObservable' | 'isEmpty' | 'add' | 'upsert'>, + enableViewUpdates: boolean ): { dispatch: (event: AssembledRumEvent) => void; stop: () => void } { let lastSentView: RumViewEvent | undefined // Base used to compute the aggregate diff for the current batch's view_update. @@ -96,7 +91,7 @@ export function createBatchDispatcher( return } - if (!isExperimentalFeatureEnabled(ExperimentalFeature.PARTIAL_VIEW_UPDATES)) { + if (!enableViewUpdates) { // Feature OFF: existing behavior — upsert full view batch.upsert(serverRumEvent, serverRumEvent.view.id) return @@ -195,7 +190,7 @@ export function startRumBatch( }) sessionExpireObservable.subscribe(() => batch.forceFlush('session_expire')) - const { dispatch, stop: stopDispatcher } = createBatchDispatcher(batch) + const { dispatch, stop: stopDispatcher } = createBatchDispatcher(batch, configuration.betaEnableViewUpdates) lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, dispatch) const stopBatch = batch.stop.bind(batch) diff --git a/test/e2e/scenario/rum/partialViewUpdates.scenario.ts b/test/e2e/scenario/rum/partialViewUpdates.scenario.ts index 56c44f3913..fabdb4a1bf 100644 --- a/test/e2e/scenario/rum/partialViewUpdates.scenario.ts +++ b/test/e2e/scenario/rum/partialViewUpdates.scenario.ts @@ -4,7 +4,7 @@ import { createTest, waitForRequests } from '../../lib/framework' test.describe('partial view updates', () => { createTest('should send a view_update event when the update arrives in a new batch') .withRum({ - enableExperimentalFeatures: ['partial_view_updates'], + betaEnableViewUpdates: true, }) .run(async ({ intakeRegistry, flushEvents, page }) => { // Flush the initial VIEW so it lands in its own batch. Any update that arrives @@ -38,7 +38,7 @@ test.describe('partial view updates', () => { createTest('should upsert the full VIEW when a view update arrives in the same batch') .withRum({ - enableExperimentalFeatures: ['partial_view_updates'], + betaEnableViewUpdates: true, }) .run(async ({ intakeRegistry, flushEvents, page }) => { // Trigger a view update without flushing first — it lands in the same batch as the initial VIEW. @@ -59,7 +59,7 @@ test.describe('partial view updates', () => { createTest('should have strictly increasing document_version across view and view_update events') .withRum({ - enableExperimentalFeatures: ['partial_view_updates'], + betaEnableViewUpdates: true, }) .run(async ({ intakeRegistry, flushEvents, page }) => { // Flush the initial VIEW first so the subsequent update lands in a new batch (opt-2 path). @@ -107,7 +107,7 @@ test.describe('partial view updates', () => { createTest('should emit a new full view event after navigation') .withRum({ - enableExperimentalFeatures: ['partial_view_updates'], + betaEnableViewUpdates: true, }) .run(async ({ intakeRegistry, flushEvents, page }) => { await page.evaluate(() => history.pushState(null, '', '/new-page')) @@ -125,7 +125,7 @@ test.describe('partial view updates', () => { createTest('should include required fields in all view_update events') .withRum({ - enableExperimentalFeatures: ['partial_view_updates'], + betaEnableViewUpdates: true, }) .run(async ({ intakeRegistry, flushEvents, page }) => { // Flush the initial VIEW first so the update lands in a new batch (opt-2 path). @@ -154,7 +154,7 @@ test.describe('partial view updates', () => { createTest('should send a full VIEW event (not view_update) with is_active false when view ends') .withRum({ - enableExperimentalFeatures: ['partial_view_updates'], + betaEnableViewUpdates: true, }) .run(async ({ intakeRegistry, flushEvents, page }) => { await page.evaluate(() => history.pushState(null, '', '/other-page')) @@ -176,7 +176,7 @@ test.describe('partial view updates', () => { createTest('should emit a full view checkpoint event during a long-lived view') .withRum({ - enableExperimentalFeatures: ['partial_view_updates'], + betaEnableViewUpdates: true, }) .run(async ({ intakeRegistry, flushEvents, page }) => { // Flush the initial VIEW first so it arrives at the intake in its own batch.