Skip to content

♻️ [PANA-5982] Make the serialization code more configurable and testable#4185

Open
sethfowler-datadog wants to merge 1 commit intoseth.fowler/PANA-5947-convert-change-records-to-v1-records-using-a-vdomfrom
seth.fowler/PANA-5982-make-the-serialization-code-more-configurable-and-testable
Open

♻️ [PANA-5982] Make the serialization code more configurable and testable#4185
sethfowler-datadog wants to merge 1 commit intoseth.fowler/PANA-5947-convert-change-records-to-v1-records-using-a-vdomfrom
seth.fowler/PANA-5982-make-the-serialization-code-more-configurable-and-testable

Conversation

@sethfowler-datadog
Copy link
Contributor

@sethfowler-datadog sethfowler-datadog commented Feb 13, 2026

Motivation

Change records are a new, experimental data format for session replay data that's designed to record the same information in a more compact, more compressible form.

We've already landed support for recording full snapshots as Change records behind an experiment flag. The next step is to introduce support for incremental snapshots, as well. This requires making improvements to a number of different aspects of the recording code. This PR is the fourth in a series that will work toward the goal of supporting incremental snapshots; for the previous PR, see #4165.

This PR focuses on making testing and verification of the serialization code easier by making it simpler to switch between the V1 and Change serializers.

Up to now, the only mechanism we had for switching between the serialization implementations was to change the feature flag. This doesn't cause problems for testing the full snapshot code because that code already cleanly separates the "driver" (startFullSnapshots.ts) from the serialization code (serializeNode.ts and friends). The feature flag just tells the driver which serialization implementation to use; it doesn't affect the behavior of the serialization implementations themselves. Most tests are written against the serialization implementations directly, so they don't need to manipulate the feature flag. For the few tests that involve the driver, it's enough to simply change the feature flag and start it, because the driver takes a full snapshot immediately at startup.

Problem 1: The incremental snapshot code did not provide such a clean separation. The same file, trackMutation.ts, contains both the driver and the serialization code, and there's not a clean interface between the two.

Problem 2: We need a bit more flexibility to test the incremental snapshot code than we needed for full snapshots; to verify that the V1 and Change implementations produce the same results, we need to allow both implementations to run simultaneously so that they can both handle the same sequence of events. This makes relying totally on a feature flag awkward even in the driver; in testing contexts, it would really be much better if we could configure the driver to use one implementation or the other at startup.

Problem 3: Even once we resolve the other problems, the tests for incremental snapshots in trackMutation.spec.ts need refactoring. The tests are complex and have multiple steps, and they're currently written in a style which makes it very hard to swap between implementations or to compare one implementation against the other.

Changes

To address problem 1: To make it easier to select a specific serialization implementation, with or without the driver, I've made the following changes:

  1. The code in trackMutation.ts has been split into two parts: the code that serializes a MutationRecord[], which now lives in serializeMutations.ts, and the driver, which remains in trackMutation.ts.
  2. The tests in trackMutation.spec.ts have been split as well: most tests remain in trackMutation.spec.ts, because they test both the serialization logic and the driver, but some tests that involve only serialization helper functions have moved to serializeMutations.spec.ts.

To address problem 2: To eliminate the need to manipulate the feature flag to test the different variations of the serialization code, I've made the following changes:

  1. The driver in trackMutation.ts can now be configured to use a particular serialization implementation by passing in a SerializeMutationsCallback. If you don't one pass one, the default implementation is picked by the defaultSerializeMutationsCallback() helper function. (It's trivial now since there's only one, but soon there will be two!)
  2. The exact same pattern has been applied for full snapshots, too; the driver in startFullSnapshots.ts can be configured by passing in a SerializeFullSnapshotCallback, with the default selected by defaultSerializeFullSnapshotCallback() if you don't provide one.
  3. A small amount of top-level full snapshot serialization code has been moved from startFullSnapshots.ts into serializeFullSnapshot.ts and serializeFullSnapshotAsChange.ts; the new files have been designed to match the pattern established by serializeMutations.ts. The goal here is to ensure that the code for full snapshots and incremental snapshots follows a similar structure. serializeFullSnapshot.ts has absorbed the contents of serializeDocument.ts, since serializeFullSnapshot.ts is the only non-test caller of serializeDocument.ts.

To address problem 3: To make it easier to run the tests in trackMutation.spec.ts against both serialization algorithms, I've restructured the tests so that each test no longer takes its own full snapshot, creates its own mutation tracker, and handles its own flushing. Instead, all of this functionality has moved into a helper called recordMutation(). Every test now just calls recordMutation(), passing in a callback that actually performs the mutation we're interested in, and then makes assertions against the results. Note that the tests haven't changed; they've just been refactored.

Test instructions

These changes are pure refactoring, and should not change the behavior of the code at all except in testing scenarios. So, it should be enough to test that recording still works on staging with the browser SDK extension, using the Live Replay tab.

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

@sethfowler-datadog sethfowler-datadog requested review from a team as code owners February 13, 2026 17:31
@datadog-datadog-prod-us1
Copy link

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

✅ Tests

🎉 All green!

❄️ No new flaky tests detected
🧪 All tests passed

🎯 Code Coverage (details)
Patch Coverage: 80.82%
Overall Coverage: 77.04% (+0.02%)

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

@cit-pr-commenter-54b7da
Copy link

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

Bundles Sizes Evolution

📦 Bundle Name Base Size Local Size 𝚫 𝚫% Status
Rum 169.44 KiB 169.44 KiB 0 B 0.00%
Rum Profiler 4.29 KiB 4.29 KiB 0 B 0.00%
Rum Recorder 24.71 KiB 24.87 KiB +164 B +0.65%
Logs 56.72 KiB 56.72 KiB 0 B 0.00%
Flagging 944 B 944 B 0 B 0.00%
Rum Slim 126.29 KiB 126.29 KiB 0 B 0.00%
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.0054 0.004 -25.93%
RUM - add action 0.0271 0.0131 -51.66%
RUM - add error 0.0199 0.0125 -37.19%
RUM - add timing 0.0044 0.0031 -29.55%
RUM - start view 0.0215 0.0125 -41.86%
RUM - start/stop session replay recording 0.0013 0.0007 -46.15%
Logs - log message 0.0268 0.0143 -46.64%
🧠 Memory Performance
Action Name Base Memory Consumption Local Memory Consumption 𝚫
RUM - add global context 27.00 KiB 27.80 KiB +814 B
RUM - add action 50.66 KiB 51.34 KiB +705 B
RUM - add timing 27.03 KiB 26.95 KiB -85 B
RUM - add error 56.60 KiB 55.79 KiB -831 B
RUM - start/stop session replay recording 25.72 KiB 25.87 KiB +151 B
RUM - start view 454.46 KiB 454.82 KiB +366 B
Logs - log message 46.36 KiB 46.35 KiB -14 B

🔗 RealWorld

@sethfowler-datadog sethfowler-datadog force-pushed the seth.fowler/PANA-5947-convert-change-records-to-v1-records-using-a-vdom branch from b999f89 to a80cfd8 Compare February 13, 2026 17:37
@sethfowler-datadog sethfowler-datadog force-pushed the seth.fowler/PANA-5982-make-the-serialization-code-more-configurable-and-testable branch from 7d1e657 to 30f61f6 Compare February 13, 2026 17:38
Comment on lines +19 to +38
serializeInTransaction(kind, emitRecord, emitStats, scope, (transaction: SerializationTransaction) => {
const defaultPrivacyLevel = transaction.scope.configuration.defaultPrivacyLevel

// We are sure that Documents are never ignored, so this function never returns null.
const node = serializeNode(document, defaultPrivacyLevel, transaction)!

const record: BrowserFullSnapshotRecord = {
data: {
node,
initialOffset: {
left: getScrollX(),
top: getScrollY(),
},
},
type: RecordType.FullSnapshot,
timestamp,
}
transaction.add(record)
})
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is just the old serializeDocument() function, merged into the code block that called it in startFullSnapshots.ts. The boundary between "driver" code and serialization code is now at the serializeInTransaction() call, for both full snapshots and incremental snapshots.

scope,
(transaction: SerializationTransaction) => processMutations(timestamp, mutations, transaction)
)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This entire file is just the old contents of trackMutation.ts, minus the top-level driver. There are no changes in the actual implementation code; I'm just moving things around.

node = serializeDocument(document, transaction)
}
)
serializeFullSnapshot(0 as TimeStamp, SerializationKind.INITIAL_FULL_SNAPSHOT, document, emitRecord, noop, scope)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Other than the code in startFullSnapshots.ts that became serializeFullSnapshot(), this test helper was the only code that called serializeDocument(). I've reworked it to use startFullSnapshot() instead.

Comment on lines +59 to +90
function recordMutation(
mutation: () => void,
options: {
mutationBeforeTrackingStarts?: () => void
scope?: RecordingScope
skipFlush?: boolean
} = {}
): {
mutationTracker: MutationTracker
serializedDocument: DocumentNode & SerializedNodeWithId
} {
const scope = options.scope || getRecordingScope()

const serializedDocument = takeFullSnapshotForTesting(scope)

if (options.mutationBeforeTrackingStarts) {
options.mutationBeforeTrackingStarts()
}

const mutationTracker = trackMutation(document, emitRecordCallback, emitStatsCallback, scope, serializeMutations)
registerCleanupTask(() => {
mutationTracker.stop()
})

mutation()

appendElement('<div></div>', sandbox)
if (!options.skipFlush) {
mutationTracker.flush()
}

return { mutationTracker, serializedDocument }
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wish the diff formatted this change better; it's better just to look at this as a new function. recordMutation() is the core helper that all of the other tests now use. It supports various configuration options for a few tests that do slightly weirder things, but at its core, the flow is:

  1. It takes a full snapshot.
  2. It starts mutation tracking, which adds a MutationObserver to the DOM. From this point, everything that happens will be processed by the mutation serialization code.
  3. It runs the mutation() callback provided by the test, which changes something in the DOM.
  4. It calls MutationTracker#flush() to make sure that all of the changes are serialized.
  5. It returns the mutation tracker (to let the test make assertions about the mutations) and the full snapshot (since this is needed to "play back" the recorded mutations and reconstruct the document to make assertions).

Comment on lines +85 to 89
function defaultSerializeFullSnapshotCallback(): SerializeFullSnapshotCallback {
return isExperimentalFeatureEnabled(ExperimentalFeature.USE_CHANGE_RECORDS)
? serializeFullSnapshotAsChange
: serializeFullSnapshot
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

defaultSerializeMutationsCallback() will follow this exact same pattern as soon as I add serializeMutationsAsChange.ts; look for that in an upcoming PR!

@@ -0,0 +1,113 @@
import type { RecordingScope } from '../recordingScope'
import { createRecordingScopeForTesting } from '../test/recordingScope.specHelper'
import { idsAreAssignedForNodeAndAncestors, sortAddedAndMovedNodes } from './serializeMutations'
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Everything in this file has been moved from trackMutation.spec.ts. There are no changes; I just moved it because the code it tests has been moved to serializeMutations.ts.

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