Skip to content

feat(experimental): partial view updates — view_update event type#4230

Draft
andreicoj wants to merge 9 commits intomainfrom
experimental/view-update-partial
Draft

feat(experimental): partial view updates — view_update event type#4230
andreicoj wants to merge 9 commits intomainfrom
experimental/view-update-partial

Conversation

@andreicoj
Copy link

@andreicoj andreicoj commented Feb 24, 2026

Problem

RUM VIEW events are expensive. Every update to a view — a new action, a resource, a CLS shift, a scroll — emits a full VIEW event with ~1–2 KB of repeated static data (usr, context, connectivity, feature_flags, service, version, configuration, etc.). Most of this is identical across all updates for the same view lifetime. At scale, this generates significant unnecessary ingestion volume.

Solution

Introduces a new view_update event type — a partial view update that carries only the fields that changed since the last VIEW event for that view. All static/session-level fields are omitted when unchanged, reducing average per-update size by ~75%.

The feature is gated behind an experimental feature flag: VIEW_UPDATE.


Architecture Overview

New event type: view_update

view_update is structurally distinct from view:

  • Always contains: type, application, date, view.id, view.time_spent, session.id, _dd.document_version
  • Conditionally contains: any field that changed since the last VIEW snapshot
  • Never contains: view.is_active (VUs are only emitted for active views; view end always sends a full VIEW)

Four-layer implementation

1. Schema layer (rum-events-format submodule)

  • New view_update-schema.json defining the partial event shape
  • Updated rum-events-schema.json and rum-events-browser-schema.json to include view_update in the oneOf
  • Generated rumEvent.types.ts updated with RumViewUpdateEvent type

2. Diff engine (viewCollection.ts)

  • snapshotStore: Map<viewId, ViewSnapshot> — tracks the last emitted full VIEW event per view
  • On VIEW_UPDATED (doc_version > 1, feature enabled): computes a diff via processViewDiff() and emits view_update; refreshes the snapshot
  • processViewDiff() explicitly diffs all view fields: counters (action/error/long_task/resource/frustration), web vitals (loading_time, CLS, INP, FCP, LCP, FID, dom timings, first_byte), custom_timings, replay_stats, scroll/display
  • Omits performance object (redundant — each sub-metric is already sent as an individual flat field)
  • Periodic full-VIEW refresh: falls back to a full VIEW every 50 updates or 5 minutes (aligned with session keep-alive), whichever comes first — prevents unbounded delta accumulation

3. Post-assembly strip (startRumBatch.ts)

  • After assembly, VIEW events are cached in assembledViewSnapshots (separate from the diff-engine snapshot, operates on the fully assembled + enriched event)
  • Before a view_update is batched, stripUnchangedFields() removes session-static top-level fields equal to the snapshot: usr, context, connectivity, feature_flags, service, version, source, synthetics, ci_test, ddtags, and any future new fields automatically (generic key loop, not an explicit allowlist)
  • Sub-field stripping for: _dd.{format_version, sdk_name, configuration, browser_sdk_version}, session.type, view.{name, url, referrer}, display.viewport
  • Backfill: after stripping, fields that were absent on the baseline VIEW but present on a VU (e.g. usr set after init) are written back into the snapshot so subsequent VUs can strip them when unchanged
  • Routing and transport: VUs go through batch.add() (not batch.upsert()), so each one is preserved independently — there is no deduplication

4. Context plumbing (domainContext.types.ts, assembly.ts, featureFlagContext, sessionContext)

  • view_update events correctly excluded from feature flag tracking, session replay stats, and other context hooks that should only apply to full VIEW events

Compile-time safety net (viewCollection.ts)

A TypeScript exhaustiveness guard asserts at compile time that every field in RawRumViewUpdateEvent['view'] is either explicitly diffed, always present, or explicitly omitted with justification. Adding a new field to the schema without updating processViewDiff will produce a type error.


Measured Bandwidth Savings

Benchmarked with a 3-minute headless Playwright session (42 view updates):

Metric Before (full VIEW) After (view_update) Savings
Average event size ~1,600 B ~402 B −75%
is_active sent 42/42 0/42
performance sent 42/42 0/42
display.scroll (unchanged) 42/42 2/42
feature_flags 42/42 9/42 (on change only)

Files Changed

File What changed
rum-events-format/ (submodule) New view_update-schema.json; added to both rum-events-schema.json and rum-events-browser-schema.json
packages/rum-core/src/rumEvent.types.ts Generated: new RumViewUpdateEvent type, added to RumEvent union
packages/rum-core/src/rawRumEvent.types.ts New RawRumViewUpdateEvent interface + VIEW_UPDATE in RumEventType enum
packages/core/src/tools/experimentalFeatures.ts New VIEW_UPDATE experimental flag
packages/rum-core/src/domain/view/viewCollection.ts Core diff engine: snapshot store, processViewDiff, periodic refresh, exhaustiveness guard
packages/rum-core/src/transport/startRumBatch.ts Post-assembly strip, backfill, view_update routed to batch.add()
packages/rum-core/src/domainContext.types.ts Exclude view_update from context hook types
packages/rum-core/src/domain/assembly.ts Skip assembly enrichment for view_update events
packages/rum-core/src/domain/contexts/featureFlagContext.ts Don't track feature flags from view_update events
packages/rum-core/src/domain/contexts/sessionContext.ts Exclude view_update from session context tracking
developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx Add view_update to event color map

Key Design Decisions

Why two snapshot stores?
The diff engine snapshot (in viewCollection.ts) operates on the raw RUM event before assembly enrichment. The post-assembly snapshot (in startRumBatch.ts) operates on the fully assembled event with all context fields merged in. Both are needed: the diff engine computes view-specific deltas, while the post-assembly strip removes session-level fields that are only known after assembly.

Why batch.add() not batch.upsert() for view_update?
upsert deduplicates by view ID, keeping only the latest. VUs must all be preserved — each one represents an independent delta. Using add() ensures they're all written to the encoder in order.

Why a generic strip loop instead of an explicit allowlist?
An explicit allowlist (the original approach) would silently pass through any new top-level field added to the schema, sending it redundantly on every VU. The generic approach strips anything equal to the snapshot, so new fields are handled automatically. Only routing keys (type, application, date, view, session, _dd) are preserved unconditionally.

Why periodic full-VIEW refresh?
Prevents the downstream decoder from accumulating unbounded deltas if events are lost or received out of order. The 50-update / 5-minute thresholds are conservative for the initial rollout.


Testing

  • Unit tests: 28 new tests in viewCollection.spec.ts covering the diff engine (counters, vitals, scroll, CLS, custom timings, replay stats, periodic refresh, snapshot lifecycle), plus 35 new tests in startRumBatch.spec.ts covering the post-assembly strip (all field types, backfill, snapshot eviction on view end)
  • Schema validation: all view_update events emitted in tests are validated against the JSON schema via collectAndValidateRawRumEvents
  • All 3,267 existing tests pass (1 pre-existing skip unrelated to this change)

Out of Scope / Follow-up

  • Decoder/intake-side changes to reconstruct full VIEW from deltas (backend work)
  • Removing the performance sub-object from full VIEW events (separate cleanup)
  • check-staging-merge CI failure: expected — submodule pointer conflicts with staging-09; resolves once the rum-events-format PR lands on main

…ure flags, type plumbing

- view_update-schema.json: new partial view update schema with optional view fields
- Registered in rum-events-browser-schema.json + rum-events-schema.json
- Regenerated rumEvent.types.ts → RumViewUpdateEvent generated type
- ExperimentalFeature.VIEW_UPDATE + VIEW_UPDATE_CHAOS added
- RumEventType.VIEW_UPDATE = 'view_update' added to const object
- RawRumViewUpdateEvent interface with all view fields optional
- RawRumEvent and AssembledRumEvent unions updated
- assembly.ts: VIEW_UPDATE modifiable paths + beforeSend denylist
- domainContext.types.ts: VIEW_UPDATE → RumViewEventDomainContext
- trackEventCounts.ts: skip view_update events
- test/fixtures.ts: VIEW_UPDATE case in createRawRumEvent()

Signed-off-by: Andrei Cojocaru <andrei.cojocaru@datadoghq.com>
…ted by VIEW_UPDATE feature flag

Signed-off-by: Andrei Cojocaru <andrei.cojocaru@datadoghq.com>
…or assembly/tracking/contexts

Signed-off-by: Andrei Cojocaru <andrei.cojocaru@datadoghq.com>
…sport unit tests

Signed-off-by: Andrei Cojocaru <andrei.cojocaru@datadoghq.com>
Signed-off-by: Andrei Cojocaru <andrei.cojocaru@datadoghq.com>
Signed-off-by: Andrei Cojocaru <andrei.cojocaru@datadoghq.com>
…ine coverage guard

- Fix is_active always sent in VUs (always true — removed from processViewDiff)
- Fix performance object duplicating individual web vital fields (removed from processViewDiff)
- Fix display.scroll sent on every VU due to Duration/ServerDuration unit mismatch in comparison
- Fix feature_flags leaked on every VU (backfill bug — now correctly backfilled after first VU)
- Genericize stripUnchangedFields: routing keys (type/application/date/view/session/_dd) are
  always kept; everything else is stripped if equal to snapshot — new top-level fields handled
  automatically without code changes
- Genericize snapshot backfill loop to cover future fields automatically
- Add TypeScript compile-time exhaustiveness guard on RawRumViewUpdateEvent['view'] fields:
  adding a new schema field without updating processViewDiff produces a type error

Signed-off-by: Andrei Cojocaru <andrei.cojocaru@datadoghq.com>
@github-actions
Copy link

github-actions bot commented Feb 24, 2026

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

@datadog-datadog-prod-us1
Copy link

datadog-datadog-prod-us1 bot commented Feb 24, 2026

✅ Tests

🎉 All green!

❄️ No new flaky tests detected
🧪 All tests passed

🎯 Code Coverage (details)
Patch Coverage: 55.79%
Overall Coverage: 76.64% (-0.55%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 836d653 | Docs | Datadog PR Page | Was this helpful? Give us feedback!

@cit-pr-commenter-54b7da
Copy link

cit-pr-commenter-54b7da bot commented Feb 24, 2026

Bundles Sizes Evolution

📦 Bundle Name Base Size Local Size 𝚫 𝚫% Status
Rum 171.96 KiB 180.00 KiB +8.04 KiB +4.67%
Rum Profiler 4.67 KiB 4.67 KiB 0 B 0.00%
Rum Recorder 24.88 KiB 24.88 KiB 0 B 0.00%
Logs 56.29 KiB 56.36 KiB +68 B +0.12%
Flagging 944 B 944 B 0 B 0.00%
Rum Slim 127.73 KiB 135.55 KiB +7.82 KiB +6.12%
Worker 23.63 KiB 23.63 KiB 0 B 0.00%
🚀 CPU Performance
Action Name Base CPU Time (ms) Local CPU Time (ms) 𝚫%
RUM - add global context 0.0086 0.0041 -52.33%
RUM - add action 0.0267 0.0137 -48.69%
RUM - add error 0.0205 0.0136 -33.66%
RUM - add timing 0.0047 0.0027 -42.55%
RUM - start view 0.0159 0.0131 -17.61%
RUM - start/stop session replay recording 0.001 0.0006 -40.00%
Logs - log message 0.0215 0.0145 -32.56%
🧠 Memory Performance
Action Name Base Memory Consumption Local Memory Consumption 𝚫
RUM - add global context 25.94 KiB 28.14 KiB +2.20 KiB
RUM - add action 112.96 KiB 52.40 KiB -60.55 KiB
RUM - add timing 27.34 KiB 26.04 KiB -1.30 KiB
RUM - add error 115.92 KiB 56.68 KiB -59.24 KiB
RUM - start/stop session replay recording 25.90 KiB 25.23 KiB -688 B
RUM - start view 506.44 KiB 449.69 KiB -56.76 KiB
Logs - log message 46.62 KiB 44.91 KiB -1.71 KiB

🔗 RealWorld

- Fix curly rule: add braces to single-statement if bodies
- Fix unused destructured vars: replace destructuring-to-omit with
  delete for _dd.*, session.type, view.name/url/referrer, display.viewport
- Fix no-unsafe-call: use String() instead of ((x as any).y).slice()
- Fix no-unnecessary-type-assertion: remove redundant snapshot! non-null
  assertions (TypeScript narrows correctly via shouldSendFull guard)
- Fix unused vars: eslint-disable for _vuViewFieldsCoverage coverage guard,
  rename configuration → _configuration in processViewDiff (param unused)
- Fix spec no-unnecessary-type-assertion: remove redundant `as string`
  casts on interceptor.requests[0].body (already typed as string)
- Fix spec no-unsafe-return: cast JSON.parse result to Record<string, any>
- Fix spec camelcase: rename ci_test variable to ciTest
- Fix spec typecheck: access is_active via (event.view as any).is_active
- Fix eventRow.tsx typecheck: add view_update to RUM_EVENT_TYPE_COLOR map
- Fix schema: commit view_update-schema.json and update rum-events-schema.json
  and rum-events-browser-schema.json to include view_update type; submodule
  pointer updated to new commit on view-update-schema branch

Signed-off-by: Andrei Cojocaru <andrei.cojocaru@datadoghq.com>
Copy link
Author

I have read the CLA Document and I hereby sign the CLA

Signed-off-by: Andrei Cojocaru <andrei.cojocaru@datadoghq.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant