Skip to content

⚗️ Partial view updates (experimental) - #4201

Merged
mormubis merged 16 commits into
mainfrom
adlrb/partial-view
May 27, 2026
Merged

⚗️ Partial view updates (experimental)#4201
mormubis merged 16 commits into
mainfrom
adlrb/partial-view

Conversation

@mormubis

@mormubis mormubis commented Feb 18, 2026

Copy link
Copy Markdown
Contributor

Motivation

Every periodic view update was sending the full view payload even when only one counter changed. Benchmarks showed 50–90% of the data was redundant. This implements the partial view updates RFC to reduce bandwidth by sending only changed fields in subsequent view events.

Aligned with rum-events-format #355 (now merged).

Changes

When partial_view_updates is enabled, the SDK sends the first event per view.id as a full view, then sends view_update diffs with only changed fields. The diff runs post-assembly in startRumBatch.ts so beforeSend always sees the full event (backward-compatible). view_update events bypass the assembly pipeline intentionally, they are a bandwidth optimization and not a customer-visible event type.

A full view checkpoint is sent every 100 updates for backend recovery. Checkpoints can be disabled with partial_view_updates_no_checkpoint.

view_update events use batch.add instead of upsert, so a batch can contain a full view followed by view_update events. This is intentional: if we consolidated them, we wouldn't be able to tell if the backend missed an intermediate update or it was never sent.

Test instructions

yarn test:unit
yarn test:e2e:init && yarn test:e2e -g "partial view"

Or manually:

datadogRum.init({
  enableExperimentalFeatures: ['partial_view_updates'],
})

Checklist

  • Tested locally
  • Tested on staging
  • Added unit tests for this change.
  • Added e2e/integration tests for this change.
  • Updated documentation and/or relevant AGENTS.md file

@github-actions

github-actions Bot commented Feb 18, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Feb 18, 2026

Copy link
Copy Markdown

Bundles Sizes Evolution

📦 Bundle Name Base Size Local Size 𝚫 𝚫% Status
Rum 173.61 KiB 175.47 KiB +1.86 KiB +1.07%
Rum Profiler 8.07 KiB 8.08 KiB +1 B +0.01%
Rum Recorder 21.23 KiB 21.23 KiB +1 B +0.00%
Logs 56.96 KiB 57.01 KiB +46 B +0.08%
Rum Slim 131.28 KiB 133.08 KiB +1.80 KiB +1.37%
Worker 22.99 KiB 22.99 KiB 0 B 0.00%

🔗 RealWorld

@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Feb 18, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 45.00%
Overall Coverage: 76.57% (-0.29%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 9e2c9e6 | Docs | Datadog PR Page | Give us feedback!

Comment thread packages/rum-core/src/domain/contexts/sourceCodeContext.ts

Copy link
Copy Markdown

Nice work on this — the diff engine design looks solid, and the REPLACE/MERGE/APPEND strategy categorization is clean. I've been prototyping a parallel implementation against our staging backend and wanted to share some observations.

High severity

1. No post-assembly strip (~500-650B wasted per view_update)

The diff in viewDiff.ts operates on the raw RawRumViewEvent before assembly. Assembly then adds these fields to every view_update identically to full view events:

  • usr, context, connectivity (~150-350B conditional)
  • _dd.configuration (~143B)
  • ddtags (~88B), service+version (~45B), source (~9B)
  • display.viewport (~27B), view.url+view.referrer (~44B)
  • _dd.sdk_name, _dd.format_version, session.type (~12B)
  • feature_flags when unchanged (~40-400B depending on flag count)

These fields have REPLACE semantics — they don't change between updates. In my prototype I added a second pass in startRumBatch.ts that stores the last assembled VIEW per view ID and strips unchanged REPLACE-semantics fields from subsequent view_updates (constructing a new object, not mutating). Steady-state savings: ~523B/VU base + ~56B per flag-set change. Without this, most of the bandwidth savings from the diff engine are negated by assembly overhead.

2. No periodic full VIEW refresh (no recovery from dropped events)

If any view_update is lost (network failure, batch timeout, intake hiccup), the backend's merged state drifts for the entire view lifetime with no self-healing. For SPAs where views can live for minutes or hours, this is a persistent silent corruption risk.

Suggestion: force a full view event every N updates or every T seconds (e.g., every 10 updates or 60s). Acts as a recovery checkpoint. The backend receives a complete snapshot and resets its merge state from that point.

3. No full VIEW on view end

When is_active goes false, the current diff sends a view_update containing only the changed fields from the last snapshot. If any earlier updates were lost, the final terminal state in the backend is incomplete.

Suggestion: always emit a full view event (not a diff) when is_active: false. This guarantees a complete final snapshot regardless of any prior losses.

Medium severity

4. _dd.page_states APPEND semantics are lossy on drop

If a view_update carrying new page_states entries is dropped, those foreground/background transitions are permanently unrecoverable — subsequent appends only send elements added after the last sent state. Given page_states is used for foreground time calculations and session replay stitching, this is a data quality risk.

One option: skip page_states entirely from view_update (let them be captured in periodic full VIEW refreshes as in point 2). Another option: always send the full page_states array on change (REPLACE semantics instead of APPEND), since the array is typically small.

5. feature_flags not covered by the diff

featureFlagContext.ts adds feature_flags to every view_update via assembly hooks — outside the diff engine's scope. They're always included even when unchanged. For customers with many flags this adds meaningful bytes per event. If stripping (point 1) is added, feature_flags would be handled there naturally.

Low severity

6. No snapshot cleanup on view end

diffTracker.reset() fires on new view.id, but completed views' snapshots persist until the tracker is overwritten. Minor memory concern for long-running SPAs with many navigations. Explicitly clearing on is_active: false would bound memory to active views only.


What looks good:

  • REPLACE for custom_timings (correct — whole object semantics)
  • batch.add() instead of upsert() for view_updates (each delta must be independently routable)
  • beforeSend protection (consistent with view)
  • Fallback to full view on diff failure
  • Empty diff = no event emitted
  • Deep clone in diffTracker (safe from mutation)
  • document_version always required in view_update

@mormubis
mormubis force-pushed the adlrb/partial-view branch 4 times, most recently from 1950458 to 4ca0710 Compare March 12, 2026 11:26
@mormubis
mormubis force-pushed the adlrb/partial-view branch 3 times, most recently from 40aee0f to c60c158 Compare March 18, 2026 11:34
@mormubis

Copy link
Copy Markdown
Contributor Author

/to-staging

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Mar 18, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-03-18 11:55:05 UTC ℹ️ Start processing command /to-staging


2026-03-18 11:55:11 UTC ℹ️ Branch Integration: starting soon, merge expected in approximately 0s (p90)

Commit c60c158a5c will soon be integrated into staging-12.


2026-03-18 12:14:47 UTC ℹ️ Branch Integration: this commit was successfully integrated

Commit c60c158a5c has been merged into staging-12 in merge commit f3d007064e.

Check out the triggered DDCI request.

If you need to revert this integration, you can use the following command: /code revert-integration -b staging-12

gh-worker-dd-mergequeue-cf854d Bot added a commit that referenced this pull request Mar 18, 2026
Integrated commit sha: c60c158

Co-authored-by: mormubis <adrian.delarosa@datadoghq.com>
@mormubis
mormubis force-pushed the adlrb/partial-view branch from c60c158 to 52a76bb Compare March 30, 2026 09:04
@mormubis

Copy link
Copy Markdown
Contributor Author

/to-staging

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Mar 30, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-03-30 09:53:33 UTC ℹ️ Start processing command /to-staging


2026-03-30 09:53:40 UTC ℹ️ Branch Integration: starting soon, merge expected in approximately 0s (p90)

Commit 52a76bbd97 will soon be integrated into staging-14.


2026-03-30 10:15:17 UTC ℹ️ Branch Integration: this commit was successfully integrated

Commit 52a76bbd97 has been merged into staging-14 in merge commit 1cf29217c5.

Check out the triggered DDCI request.

If you need to revert this integration, you can use the following command: /code revert-integration -b staging-14

gh-worker-dd-mergequeue-cf854d Bot added a commit that referenced this pull request Mar 30, 2026
Integrated commit sha: 52a76bb

Co-authored-by: mormubis <adrian.delarosa@datadoghq.com>
Comment thread packages/rum-core/src/transport/startRumBatch.ts Outdated
Comment thread packages/rum-core/src/domain/view/viewDiff.ts Outdated
Comment thread packages/rum-core/src/domain/view/viewDiff.ts Outdated
Comment thread packages/rum-core/src/transport/startRumBatch.ts Outdated
Comment thread packages/rum-core/src/transport/startRumBatch.ts Outdated
Comment thread packages/rum-core/src/transport/startRumBatch.ts
@mormubis
mormubis force-pushed the adlrb/partial-view branch from 75e714c to 5e79702 Compare April 6, 2026 14:10
@mormubis
mormubis requested a review from BenoitZugmeyer April 8, 2026 13:24
@mormubis

Copy link
Copy Markdown
Contributor Author

/to-staging

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Apr 13, 2026

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-04-13 08:23:56 UTC ℹ️ Start processing command /to-staging


2026-04-13 08:24:03 UTC ℹ️ Branch Integration: starting soon, merge expected in approximately 0s (p90)

Commit 08a05063bc will soon be integrated into staging-16.


2026-04-13 08:44:13 UTC ℹ️ Branch Integration: this commit was successfully integrated

Commit 08a05063bc has been merged into staging-16 in merge commit cd062ff830.

If you need to revert this integration, you can use the following command: /code revert-integration -b staging-16

gh-worker-dd-mergequeue-cf854d Bot added a commit that referenced this pull request Apr 13, 2026
Integrated commit sha: 08a0506

Co-authored-by: mormubis <adrian.delarosa@datadoghq.com>
@mormubis
mormubis force-pushed the adlrb/partial-view branch from 08a0506 to 279d277 Compare May 13, 2026 16:03
@mormubis
mormubis requested a review from thomas-lebeau May 21, 2026 16:48
@mormubis
mormubis force-pushed the adlrb/partial-view branch from 1ce910d to c6f3d12 Compare May 21, 2026 16:55
if (viewId !== lastSentView?.view.id) {
lastSentView = serverRumEvent
viewUpdatesSinceCheckpoint = 0
batch.upsert(serverRumEvent, viewId)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sound relevant, does the backend supports this properly?

Comment thread test/e2e/lib/framework/intakeRegistry.ts Outdated
Comment thread test/e2e/scenario/rum/actions.scenario.ts Outdated
@mormubis
mormubis requested a review from thomas-lebeau May 22, 2026 13:34
Comment thread packages/rum-core/src/transport/startRumBatch.spec.ts Outdated
Comment thread packages/rum-core/src/domain/assembly.ts Outdated
Comment thread packages/rum-core/src/domain/trackEventCounts.ts Outdated
Comment thread packages/rum-core/src/transport/startRumBatch.spec.ts Outdated
Comment thread packages/rum-core/src/transport/startRumBatch.spec.ts Outdated
Comment thread packages/rum-core/src/transport/startRumBatch.ts
Comment on lines +22 to +25
export function assembleViewUpdateEvent(
current: AssembledRumEvent,
last: AssembledRumEvent
): AssembledRumEvent | undefined {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Types around this function could be greatly improved like this:

export function assembleViewUpdateEvent(current: RumViewEvent, last: RumViewEvent): RumViewUpdateEvent | undefined {
  const diff = diffMerge(current, last, {
    // context, connectivity, usr, device, privacy are objects — use REPLACE to avoid partial updates
    replaceKeys: new Set(['view.custom_timings', 'context', 'connectivity', 'usr', 'device', 'privacy']),
    appendKeys: new Set(['_dd.page_states']),
    // Ignore always-required fields — they are added back via combine regardless of changes
    ignoreKeys: new Set([
      'date',
      'type',
      'application',
      'session',
      'view.id',
      'view.url',
      '_dd.document_version',
      '_dd.format_version',
    ]),
  })

  if (!diff) {
    return undefined
  }

  // Restore the ignoreKeys — backend needs them on every event
  return combine(diff, {
    type: RumEventType.VIEW_UPDATE,
    date: current.date,
    application: current.application,
    session: current.session,
    view: {
      id: current.view.id,
      url: current.view.url,
    },
    _dd: {
      document_version: current._dd.document_version,
      format_version: current._dd.format_version,
    },
  })
}

By using the proper types RumViewEvent and RumViewUpadetEvent, you can remove all casts.

}),
})

let lastSentView: AssembledRumEvent | undefined

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: keeping track of a single view is an issue because it is possible to get updates for a view after it becomes inactive

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we discussed offline, there can only be one active view. When startView is called, the previous view gets is_active: false immediately. The view-end always sends a full upsert before the diff logic. One edge case: if a late view-end for A arrives after B started, it clears B's lastSentView. Not a correctness issue but B's next update would be a full upsert instead of a diff. I can fix that by only clearing when the ending view matches the tracked view.

Comment on lines +150 to +153
// Use setViewName to trigger unthrottled view updates (unlike addAction which is
// throttled to THROTTLE_VIEW_UPDATE_PERIOD=3s, setViewName calls triggerViewUpdate directly).
// We need more than PARTIAL_VIEW_UPDATE_CHECKPOINT_INTERVAL (100) updates to trigger a checkpoint.
// All calls are batched in a single evaluate to avoid 102 round-trips to the browser.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: This is likely a bug, no? setViewName should schedule a view update instead of sending the update directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're right. setViewName calls triggerViewUpdate directly instead of scheduleViewUpdate. I'll file it separately and fix forward.

Comment on lines +22 to +30
// Should have at least one view_update
const viewUpdateEvents = intakeRegistry.rumViewUpdateEvents
expect(viewUpdateEvents.length).toBeGreaterThanOrEqual(1)

// All events share the same view.id
const viewId = viewEvents[0].view.id
for (const update of viewUpdateEvents) {
expect(update.view.id).toBe(viewId)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: add an assertion to show that a view update contains the updated view.action.count field, so we understand why we have an addAction above


export type AssembledRumEvent = (
| RumViewEvent
| RumViewUpdateEvent

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: This types depicts the RUM events that are assembled by assembly.ts. Since ViewUpdate are not assembled by assembly.ts, it shouldn't be listed there.

@mormubis
mormubis requested a review from BenoitZugmeyer May 27, 2026 11:10
Comment thread packages/rum-core/src/rawRumEvent.types.ts Outdated
})
})

describe('startRumBatch partial_view_updates routing', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: this describe is empty, you could remove it

mormubis added 16 commits May 27, 2026 15:07
- Add PARTIAL_VIEW_UPDATES to ExperimentalFeature enum
- Add VIEW_UPDATE to RumEventType, RawRumViewUpdateEvent, and RawRumEvent union
- Add viewDiff.ts: isEqual and diffMerge utilities implementing MERGE / REPLACE /
  APPEND strategies for computing minimal diffs between assembled view events
When partial_view_updates is enabled, startRumBatch intercepts assembled view
events and sends view_update diffs instead of full views for intermediate updates.

Key design: diff runs post-assembly so beforeSend always sees full view events
(backward-compatible). view_update events intentionally bypass the assembly
pipeline — they are a bandwidth optimization, not a customer-visible event type.

- computeAssembledViewDiff: diffs two assembled view events, always including
  required routing fields (view.id, view.url, _dd.document_version, format_version)
- Routing state machine: handles new view / view-end / checkpoint / diff cases
- view-end events (is_active: false) always sent as full view
- Full view checkpoint every 100 updates for backend recovery
- Exclude view_update from trackEventCounts and assembly beforeSend guard
- Add E2E tests covering all routing cases
@mormubis
mormubis force-pushed the adlrb/partial-view branch from 5a7aa93 to 9e2c9e6 Compare May 27, 2026 13:21
@mormubis
mormubis merged commit 4839750 into main May 27, 2026
29 of 30 checks passed
@mormubis
mormubis deleted the adlrb/partial-view branch May 27, 2026 14:11
@github-actions github-actions Bot locked and limited conversation to collaborators May 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants