[RUM-15529] Integrate Session Replay#131
Conversation
This comment has been minimized.
This comment has been minimized.
47d9edb to
3783730
Compare
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 378373028c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 378373028c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| private segmentIndexPerView = new Map<string, number>(); | ||
| private viewReplayStats = new Map<string, ViewReplayStats>(); |
There was a problem hiding this comment.
❗ warning: We should prevent these maps to grow indefinitely
| if (event.lifecycle === LifecycleKind.SESSION_EXPIRED) { | ||
| this.flush('session_renew'); | ||
| } else if (event.lifecycle === LifecycleKind.SESSION_RENEW) { | ||
| this.nextCreationReason = 'session_renew'; | ||
| } |
There was a problem hiding this comment.
💬 suggestion: From Claude:
SESSION_EXPIRED flush uses wrong CreationReason -- causes premature deflate reset
flush('session_renew') resets the deflate stream at session expiry, but the new session may not start for minutes (it starts at next user activity). This means the fresh deflate context is created too early, and when SESSION_RENEW fires later it resets again, potentially producing a malformed first segment.
Fix:
if (event.lifecycle === LifecycleKind.SESSION_EXPIRED) {
this.flush('segment_duration_limit'); // flush pending; session is ending
} else if (event.lifecycle === LifecycleKind.SESSION_RENEW) {
this.deflate = new StreamingDeflate(); // fresh context for new session
this.nextCreationReason = 'session_renew';
}wdyt?
There was a problem hiding this comment.
I've addressed the changes to this code in a previous comment, but regarding: this.flush('segment_duration_limit') I don't think that is correct, as that would label the new session's first segment as if it were triggered by a timer. I believe that init is the right value here.
There was a problem hiding this comment.
That seems a bit weird to use init here and also on addRecord 🤔
What was your reference implemenation?
926b91c to
5182d1b
Compare
| * mock intake receives it with correct metadata. | ||
| */ | ||
|
|
||
| test.describe('session replay', () => { |
There was a problem hiding this comment.
💬 suggestion: we should add a scenario to exercise the mask configuration
| track: typeof EventTrack.REPLAY; | ||
| format: typeof EventFormat.REPLAY; | ||
| data: unknown; | ||
| view?: { id: string }; |
There was a problem hiding this comment.
I'd say either we rely on the contract and we'll see if it fails at some point or we enforce the contract and send some telemetry when it is not respected.
| if (event.lifecycle === LifecycleKind.SESSION_EXPIRED) { | ||
| this.flush('session_renew'); | ||
| } else if (event.lifecycle === LifecycleKind.SESSION_RENEW) { | ||
| this.nextCreationReason = 'session_renew'; | ||
| } |
There was a problem hiding this comment.
That seems a bit weird to use init here and also on addRecord 🤔
What was your reference implemenation?
…transport strategies - Extract abstract base classes from BatchProducer and BatchConsumer, moving the concrete JSON-NDJSON / JSON-array-POST logic into GenericBatchProducer and GenericBatchConsumer under a new `generic/` sub-package. - Introduce BatchFactory to own producer/consumer creation, so BatchManager stays decoupled from concrete implementations. Future tracks can add a peer package and a dispatch case in BatchFactory without touching the scheduling or the file-rotation logic.
Implement end-to-end session replay for Electron: BrowserRecord events from renderer processes are buffered into Segments with per-session ZLIB compression, then persisted to disk via a dedicated batch pipeline and uploaded as multipart/form-data to the replay intake.
- Add "records" to getCapabilities() so the browser RUM SDK detects session replay support and starts emitting BrowserRecord events through the bridge. -Replace the synchronous ipcRenderer.sendSync(CONFIG_CHANNEL) with an async retry loop using ipcRenderer.invoke. The bridge now initialises immediately with safe defaults (privacy: mask, hosts: [location.hostname]) and polls for up to 5s (50 × 100ms) for the Electron SDK's CONFIG_CHANNEL handler to become available. This decouples the preload lifecycle from SDK init order — previously the config was silently dropped if init() had not been called before the BrowserWindow was created.
- Move replay stats injection out of Assembly into a dedicated registerReplayContext hook; hooks now receive source and rendererViewId so any hook can act on renderer view events without Assembly owning replay-specific logic - Rename GenericBatch* → StandardBatch* and relocate to standard/; clarifies the distinction between the append-and-rotate strategy and the replay-specific one - Extract BatchConsumer abstract base class with shared file-scan, read, upload, and delete logic; StandardBatchConsumer and ReplayBatchConsumer each implement only buildRequest() - Delete BatchFactory and types.ts; inline configs into their respective classes and introduce batchConfig.types.ts for the BatchManager config - Add test coverage: BatchConsumer base behaviour, Assembly hook parameter forwarding, and registerReplayContext skip/inject logic
- Tighten RawReplayEvent: remove unused track fields, type data as BrowserRecord, make view required and source explicit; bridge guards against missing view - Move StreamingDeflate to src/tools/ (shared, not replay-specific) and wrap all stream callbacks with monitor for error observability - Replace globalThis.setTimeout/clearTimeout with monitored browser-core equivalents; wrap compressSegment .then callback with monitor - Fix session lifecycle: move deflate reset and per-session map clearing to SESSION_RENEW; drop the non-spec session_renew creation reason in favour of init - Convert CreationReason from string union to const object matching the full rum-events-format schema enum - Raise SEGMENT_BYTES_LIMIT from 60 KB to 10 MB - browser IPC constraint does not apply in the Electron main process - Make estimatedSize O(1) by accumulating size incrementally in addRecord
- Chain pendingFlush with Promise.all instead of overwriting it: without this, a SESSION_EXPIRED flush followed by a new-session flush before the first compression completes causes stop() to miss the expired session's last segment on quit - Append a per-instance sequence counter to batch filenames (batch-<ts>-<seq>.tmp) so two segments flushed within the same millisecond no longer collide — the timestamp-only name could cause the second rename to silently overwrite the first on POSIX or fail on Windows
5182d1b to
26064d1
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 26064d1672
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 'dd-request-id': generateUUID(), | ||
| }); | ||
|
|
||
| return new Request(`${this.intakeUrl}?${params.toString()}`, { |
There was a problem hiding this comment.
Build the replay URL without duplicating the query
When this consumer is created by BatchManager.createProducerConsumerPair(), this.intakeUrl already comes from computeIntakeUrlForTrack() and includes ?ddsource=electron (or a proxy ?ddforward=...). Appending another ? here produces URLs like /api/v2/replay?ddsource=electron?ddsource=browser&..., so the replay request no longer has ddsource=browser as intended and proxy forwarding is malformed. Build the URL by merging/replacing query params or use a replay base URL without the standard track query.
Useful? React with 👍 / 👎.
| return null; | ||
| } | ||
|
|
||
| const metadataWithSizes = JSON.parse(lines[0]) as Record<string, unknown>; |
There was a problem hiding this comment.
Drop malformed replay batch files instead of throwing
A crash during ReplayBatchProducer.writeData() can leave a partial .tmp file that rotateOrphanedBatches() later turns into .log; if that file has a truncated metadata line, this JSON.parse() throws before uploadBatch() reaches its fetch error handling or deletes the corrupt file. That aborts the upload cycle and the same file will keep blocking replay uploads on every retry, unlike the profile consumer which validates and drops malformed recovered files.
Useful? React with 👍 / 👎.
| // Guard against re-entrancy: a second quit (e.g. user hits Cmd+Q again or the | ||
| // OS sends another quit signal) must not race the fallback timer, and the | ||
| // fallback firing before the flush settles must not trigger a duplicate quit. | ||
| let done = false; |
There was a problem hiding this comment.
Hoist the quit guard out of the callback
Because done is scoped inside each before-quit invocation, a second quit request while the first _flushTransport() is still uploading gets its own done = false and starts another flush. The concurrent BatchManager.flush() path returns immediately when isUploading is already true, so that second .finally(doQuit) can remove the listener and call app.quit() before the original upload has finished, losing the replay segment this handler was added to protect.
Useful? React with 👍 / 👎.
| if (!stats) { | ||
| return SKIPPED; |
There was a problem hiding this comment.
Clear renderer replay flags when no segment was sent
When the renderer Browser SDK already put session.has_replay or _dd.replay_stats on a view update, returning SKIPPED here leaves those renderer-provided fields intact because RendererPipeline combines the hook result into the original event. In sessions that Electron samples out for replay, or before Electron has actually flushed any segment for the view, the uploaded RUM view can still claim a replay exists even though the main process drops the records. Override those replay fields when getViewReplayStats() has no stats instead of skipping.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| addRecord(record: BrowserRecord): void { | ||
| this._estimatedSize += JSON.stringify(record).length; |
There was a problem hiding this comment.
Count UTF-8 bytes for replay segment limits
This uses JavaScript string length to enforce a byte limit, but replay records can contain non-ASCII DOM text where UTF-8 takes multiple bytes per code unit. A view containing large CJK/emoji text can therefore stay under estimatedSize while the serialized segment sent to the intake is far above the intended 10 MB byte cap. Use Buffer.byteLength(JSON.stringify(record), 'utf8') (and account for framing/metadata) when deciding whether to flush.
Useful? React with 👍 / 👎.
| } | ||
| // Validate the renderer-supplied shape early: a malformed record would otherwise fail | ||
| // later at segment serialization/upload with no useful context. | ||
| if (!isIndexableObject(bridgeEvent.event)) { |
There was a problem hiding this comment.
Validate replay record fields before enqueueing
When a renderer sends an object-shaped but malformed replay record, for example after a bridge/browser SDK mismatch where timestamp is missing or non-numeric, this check accepts it and Segment.addRecord() later computes start/end from that value. Math.min(..., undefined) produces NaN, which serializes to null in the replay metadata and can make the segment unusable/rejected instead of dropping and reporting the bad IPC payload at the boundary.
Useful? React with 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
PR Review — Score: 3.8 / 5
This is a substantial, well-structured integration: replay collection, compression, batch transport, bridge wiring, and E2E coverage are thoughtfully designed and most prior review feedback has been addressed (flush chaining, unique batch filenames, hook extraction, monitored stream callbacks). I would not approve yet because the replay upload URL is constructed incorrectly against the shared computeIntakeUrlForTrack() output, which would mangle ddsource in production.
Why 3.8: Clear separation between ReplayCollection, Segment, StreamingDeflate, and replay-specific batch producer/consumer; solid unit and E2E tests (including privacy masking); transport refactor keeps future tracks extensible; session lifecycle and shutdown flush paths are mostly well thought through.
Why not 5: A blocking intake URL bug (ddsource ends up as electron?ddsource=browser); a few robustness gaps remain around corrupt on-disk batches, quit re-entrancy, and renderer has_replay false positives; sessionReplaySampleRate is not documented in the customer-facing config table.
Findings
- [Blocking] Replay intake URL — Appending
?ddsource=browser&…to an intake URL that already contains?ddsource=electronmangles query params and breaks the intendedddsource=browserconvention. - [Minor] Corrupt replay batch recovery —
JSON.parseinReplayBatchConsumer.buildRequestis unguarded; a truncated recovered.logcan abort the upload cycle indefinitely (profile consumer drops malformed files). - [Minor] Quit re-entrancy guard — The
doneflag is scoped perbefore-quitinvocation, so a second quit during an in-flight flush can callapp.quit()before the first upload finishes. - [Minor] Stale
has_replayon view events — ReturningSKIPPEDwhen no Electron replay stats exist leaves renderer-providedsession.has_replay/_dd.replay_statson the combined event. - [Minor] Segment size estimate —
estimatedSizeuses JS string length, not UTF-8 byte length, so non-ASCII DOM text can exceed the intended 10 MB cap before flush. - [Nit] Replay record validation — Bridge accepts any indexable object; a missing/non-numeric
timestampproducesNaNsegment bounds instead of being dropped at the IPC boundary. - [Nit] Missing customer docs —
sessionReplaySampleRateis validated in code but absent from the README configuration table.
Architectural flow
sequenceDiagram
participant R as Renderer (browser-rum)
participant B as DatadogEventBridge / preload
participant RP as RendererPipeline
participant RC as ReplayCollection
participant SD as StreamingDeflate
participant EM as EventManager
participant RBP as ReplayBatchProducer
participant RBC as ReplayBatchConsumer
participant I as Replay intake
R->>B: rrweb BrowserRecord
B->>RP: IPC record + view.id
RP->>EM: RawReplayEvent
EM->>RC: onRecord (sampled sessions only)
RC->>RC: Segment buffer / flush triggers
RC->>SD: compressSegment (per-session ZLIB stream)
SD-->>RC: compressed blob
RC->>EM: ServerReplayEvent (REPLAY track)
EM->>RBP: post → one .tmp/.log per segment
RBC->>RBC: read .log, build multipart/form-data
RBC->>I: POST segment + event metadata
RC->>EM: registerReplayContext hook enriches renderer view events with has_replay
Before: Renderer rrweb records had no main-process pipeline; transport only handled standard JSON NDJSON tracks (RUM/spans/profiling).
After: RendererPipeline ingests record bridge events, ReplayCollection buffers/compresses them into browser-compatible segments, a dedicated replay batch pair persists and uploads multipart payloads, and a RUM hook injects has_replay / replay_stats into renderer view events once segments are actually sent.
Sent by Cursor Automation: electron-sdk reviews
| 'dd-request-id': generateUUID(), | ||
| }); | ||
|
|
||
| return new Request(`${this.intakeUrl}?${params.toString()}`, { |
There was a problem hiding this comment.
Blocking — double ? breaks ddsource.
BatchManager passes this.intakeUrl from computeIntakeUrlForTrack(), which already ends with ?ddsource=electron (or embeds the path inside a proxy ddforward). Appending another ?${params} produces URLs like:
/api/v2/replay?ddsource=electron?ddsource=browser&…
URLSearchParams.get('ddsource') then returns electron?ddsource=browser, not browser as intended. The unit test config uses a bare replay URL without the electron query param, so this regression is not caught.
Fix options: build the replay URL from a base path without the electron ddsource, or merge params with the URL API / use & when intakeUrl already contains ?, replacing ddsource rather than appending a second query string.
| return null; | ||
| } | ||
|
|
||
| const metadataWithSizes = JSON.parse(lines[0]) as Record<string, unknown>; |
There was a problem hiding this comment.
Minor — unguarded JSON.parse can block uploads.
ProfileBatchConsumer validates JSON and returns null (dropping the file) when a recovered .log is truncated after a crash. Here, JSON.parse(lines[0]) throws before uploadBatch() reaches its fetch error handling, which aborts the entire upload cycle; the corrupt file is retried forever.
Wrap parsing in try/catch (and validate session.id / start) and return null to drop unrecoverable files, matching the profile consumer pattern.
| // Guard against re-entrancy: a second quit (e.g. user hits Cmd+Q again or the | ||
| // OS sends another quit signal) must not race the fallback timer, and the | ||
| // fallback firing before the flush settles must not trigger a duplicate quit. | ||
| let done = false; |
There was a problem hiding this comment.
Minor — quit guard should be hoisted.
done is created fresh on every before-quit invocation. If the user triggers quit again while the first _flushTransport() is still uploading, the second handler gets its own done = false, starts another flush, and BatchManager.triggerUploadCycle() returns immediately when isUploading is already true — so the second .finally(doQuit) can remove the listener and call app.quit() before the original upload completes.
Hoist a module-level let quitting = false (or a single in-flight flush promise) outside the handler so concurrent quit signals share one guard.
|
|
||
| const stats = getViewReplayStats(rendererViewId); | ||
| if (!stats) { | ||
| return SKIPPED; |
There was a problem hiding this comment.
Minor — renderer has_replay can be stale.
When getViewReplayStats() returns undefined (session sampled out on the main process, or no segment flushed yet), returning SKIPPED leaves any renderer-provided session.has_replay / _dd.replay_stats on the combined view event (combine(data, hookResult ?? {}) only runs when the hook contributes).
Consider returning explicit overrides when stats are absent, e.g. { session: { has_replay: false } } and clearing _dd.replay_stats, so uploaded RUM views do not claim a replay exists when Electron never sent one.
| } | ||
|
|
||
| addRecord(record: BrowserRecord): void { | ||
| this._estimatedSize += JSON.stringify(record).length; |
There was a problem hiding this comment.
Minor — byte limit uses string length, not UTF-8 bytes.
estimatedSize accumulates JSON.stringify(record).length (UTF-16 code units), but flush() reports rawBytesCount via Buffer.byteLength(…, 'utf8') and the 10 MB cap is meant to be a byte limit. Segments with large CJK/emoji DOM text can stay under SEGMENT_BYTES_LIMIT while the serialized segment exceeds the intended cap.
Use Buffer.byteLength(JSON.stringify(record), 'utf8') (plus framing overhead if you want to be precise) for the running estimate.
| } | ||
| // Validate the renderer-supplied shape early: a malformed record would otherwise fail | ||
| // later at segment serialization/upload with no useful context. | ||
| if (!isIndexableObject(bridgeEvent.event)) { |
There was a problem hiding this comment.
Nit — validate timestamp at the bridge boundary.
isIndexableObject accepts any object-shaped payload; if timestamp is missing or non-numeric (bridge/SDK mismatch), Segment.addRecord computes Math.min(…, undefined) → NaN, which serializes to null in metadata and can make the segment unusable.
A cheap guard here (typeof record.timestamp === 'number' && Number.isFinite(record.timestamp)) would fail loudly at the IPC boundary, consistent with the profile validation pattern above.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 26064d1672
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 'dd-request-id': generateUUID(), | ||
| }); | ||
|
|
||
| return new Request(`${this.intakeUrl}?${params.toString()}`, { |
There was a problem hiding this comment.
Append replay query params with the existing separator
When replay is enabled through normal Transport setup, BatchManager passes an intake URL from computeIntakeUrlForTrack, which already contains a query string (?ddsource=electron, or ?ddforward=... when using a proxy). Appending another ? here produces URLs like /api/v2/replay?ddsource=electron?ddsource=browser...; with a proxy, the replay auth params are also outside the forwarded URL, so the intake/proxy does not receive a valid browser replay request. Use URL/& or build a replay URL without the pre-existing query before adding these params.
Useful? React with 👍 / 👎.
| const metadataWithSizes = JSON.parse(lines[0]) as Record<string, unknown>; | ||
| const compressed = Buffer.from(lines[1], 'base64'); | ||
|
|
||
| const sessionId = (metadataWithSizes.session as { id: string }).id; |
There was a problem hiding this comment.
Drop corrupt replay batch files instead of throwing
If the app crashes while ReplayBatchProducer is writing, the base producer rotates leftover .tmp files to .log on the next startup. Those files can have a truncated first line, and this unguarded parse/deref happens before BatchConsumer's fetch try/catch, so every upload cycle throws on the same file and prevents later replay batches from being sent. Validate and drop malformed replay files like ProfileBatchConsumer does.
Useful? React with 👍 / 👎.
| }; | ||
|
|
||
| setTimeout(doQuit, 5000); | ||
| void _flushTransport().finally(doQuit); |
There was a problem hiding this comment.
Wait for in-flight batch uploads before quitting
In the before-quit path, this resumes quitting as soon as _flushTransport() resolves, but BatchManager.triggerUploadCycle() returns immediately when a scheduled upload is already running (isUploading is true). If quit happens during that window, the handler can call app.quit() before the in-flight upload finishes and before the replay segment just produced by replayCollection.stop() is flushed/uploaded. The quit handler should await the active cycle or rerun flush after it completes before removing the listener and quitting.
Useful? React with 👍 / 👎.
| this.metadata.start = Math.min(this.metadata.start, record.timestamp); | ||
| this.metadata.end = Math.max(this.metadata.end, record.timestamp); | ||
| this.metadata.records_count += 1; | ||
| if (record.type === FULL_SNAPSHOT_TYPE) { |
There was a problem hiding this comment.
Mark first change records as full snapshots
With the browser SDK 7.x change-format replay records, the first segment in a view can satisfy the full-snapshot requirement with a RecordType.Change record (type: 12) when index_in_view is 0. This condition only recognizes legacy type === 2, so those first segments are uploaded with has_full_snapshot: false even though they contain the initial snapshot, which can make the backend/player treat the replay as missing its starting snapshot.
Useful? React with 👍 / 👎.
| capabilities: config.profilingSampleRate > 0 ? ['profiles'] : [], | ||
| capabilities: [ | ||
| ...(config.profilingSampleRate > 0 ? ['profiles'] : []), | ||
| ...(config.sessionReplaySampleRate > 0 ? ['records'] : []), |
There was a problem hiding this comment.
Gate records on the effective replay sample rate
When sessionSampleRate is 0 but sessionReplaySampleRate is positive, this still advertises the records capability, so every renderer starts the expensive replay recorder and sends IPC records that ReplayCollection deterministically drops because the corrected child sample rate is 0. Check the effective replay sampling rate (or at least sessionSampleRate > 0) before exposing records to avoid doing session-replay work for configurations that can never upload a replay.
Useful? React with 👍 / 👎.
| version: validateOptionalString(initConfig.version), | ||
| proxy, | ||
| sessionSampleRate, | ||
| sessionReplaySampleRate, |
There was a problem hiding this comment.
Report replay sampling in RUM configuration
This adds sessionReplaySampleRate to the resolved configuration, but the common RUM context still only reports session_sample_rate and profiling_sample_rate in _dd.configuration. For apps enabling replay, emitted RUM events under-report the SDK sampling configuration even though the schema includes session_replay_sample_rate, making replay sampling/debugging inaccurate downstream.
Useful? React with 👍 / 👎.
| } | ||
| // Validate the renderer-supplied shape early: a malformed record would otherwise fail | ||
| // later at segment serialization/upload with no useful context. | ||
| if (!isIndexableObject(bridgeEvent.event)) { |
There was a problem hiding this comment.
Validate record timestamp and type before queuing
When a renderer sends an object payload that is missing a numeric timestamp or type (for example from a bridge version mismatch or malformed IPC), it passes this object-only check and reaches Segment.addRecord(), where Math.min/Math.max turns segment start/end into NaN and the upload metadata becomes invalid. Validate the BrowserRecord fields here and drop/report bad records before notifying the replay collection.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| addRecord(record: BrowserRecord): void { | ||
| this._estimatedSize += JSON.stringify(record).length; |
There was a problem hiding this comment.
Measure segment limits in bytes, not UTF-16 length
For replay records containing non-ASCII text (especially with defaultPrivacyLevel: 'allow'), JSON.stringify(record).length can be much smaller than the UTF-8 byte size that is actually serialized and uploaded. This lets a segment grow well past the intended 10 MB raw limit before flushing, which can produce oversized replay files/requests that the intake rejects; use byte length for the estimate and account for framing/metadata overhead.
Useful? React with 👍 / 👎.


Motivation
Implement session replay for the Electron SDK. Browser RUM SDK records DOM activity in renderer processes and sends those records through the
DatadogEventBridgeto the main process where these are buffered and compressed them into segments, and later sent to the Datadog session replay intake, enabling replay playback in the DD UI for Electron apps.Example session: https://mobile-integration.datadoghq.com/rum/replay/sessions/0b8c44f0-e8f7-4c0b-9ea8-0b0ec84d1993?applicationId=056b6201-48cb-4acc-9fbc-48507b8e3e12&seed=fe0ea26d-27d2-4108-9f30-296ff56af2bd&ts=1780479119880
Changes
Transport layer refactor (prerequisite)
BatchProducerandBatchConsumerare now abstract base classes, moving the concrete JSON-NDJSON / JSON-array-POST logic intoGenericBatchProducerandGenericBatchConsumerundersrc/transport/batch/generic/. A newBatchFactoryowns producer/consumer creation and dispatches on track type, allowing future transports to plug in without touching the scheduling or file-rotation logic.Session replay pipeline
src/domain/replay/Segment.ts— buffersBrowserRecordobjects into a segment, tracking timestamps, record count, and full-snapshot flag.flush()produces the exact wire format the backend expects (records array merged with metadata, trailing\n).src/domain/replay/StreamingDeflate.ts— maintains a single stateful ZLIB stream across all segments in a session, producing output byte-for-byte compatible with Pako (the browser SDK's compressor). Implements the cumulative Adler-32 checksum required for backend stitching.src/domain/replay/ReplayCollection.ts— listens forRawReplayEvents on the event bus, buffers records into segments, handles view changes and session lifecycle (flush on view change, session expiry, size/duration limits), compresses, and emitsServerReplayEvents for the transport layer.src/transport/batch/replay/ReplayBatchProducer.ts— writes one atomic file per compressed segment (JSON metadata line + base64 compressed body,.tmp→.logrename for atomicity).src/transport/batch/replay/ReplayBatchConsumer.ts— reads the two-line format and POSTs multipart/form-data to the session replay intake endpoint.Assembly enrichment
Assemblynow accepts agetViewReplayStatscallback and injectssession.has_replay: trueand_dd.replay_statsinto outgoing RUM view events, so the DD UI shows the replay indicator on views that have recorded data.Bridge / IPC
BridgeHandler'sCONFIG_CHANNELhandler switched fromipcMain.on+sendSynctoipcMain.handle+invoke, decoupling the preload's config fetch from SDK init order."records"togetCapabilities()— without this the browser RUM SDK never emitsBrowserRecordevents — and to replace the blockingsendSyncconfig fetch with an async retry loop that tolerates late SDK initialisation.Graceful shutdown
app.once('before-quit', ...)flushes the in-flight replay segment to disk before process exit (5 s safety timeout), ensuring the last ~5 s of recording is not lost on orderly shutdown.Useful links
https://datadoghq.atlassian.net/wiki/spaces/PANA/pages/2419392855/RUM+Session+Replay+Recorder+architecture
https://datadoghq.atlassian.net/wiki/spaces/RUMP/pages/2491842579/RFC+-+Session+Replay+Writer+Reader
Checklist