[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
| 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?
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".
| } | ||
| // 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.
Stale 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 mangleddsourcein 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 (
ddsourceends up aselectron?ddsource=browser); a few robustness gaps remain around corrupt on-disk batches, quit re-entrancy, and rendererhas_replayfalse positives;sessionReplaySampleRateis 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
LoadingsequenceDiagram 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_replayBefore: Renderer rrweb records had no main-process pipeline; transport only handled standard JSON NDJSON tracks (RUM/spans/profiling).
After:
RendererPipelineingestsrecordbridge events,ReplayCollectionbuffers/compresses them into browser-compatible segments, a dedicated replay batch pair persists and uploads multipart payloads, and a RUM hook injectshas_replay/replay_statsinto renderer view events once segments are actually sent.Sent by Cursor Automation: electron-sdk reviews
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 👍 / 👎.
| 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 👍 / 👎.
…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
a8d6ac2 to
65d3268
Compare
65d3268 to
7f92ebe
Compare
|
@codex review |
There was a problem hiding this comment.
Stale comment
PR Review — Score: 4.4 / 5
This is a well-executed Session Replay integration. The follow-up commits since the last review addressed the blocking replay intake URL bug (
appendIntakeParams), corrupt batch recovery, quit re-entrancy, segment byte accounting, bridge record validation, andhas_replayfalse-positives. Architecture, tests (unit + E2E including privacy masking), and transport extensibility are all solid. I would approve with only minor follow-ups remaining.Why 4.4: Clear module boundaries (
ReplayCollection→Segment→StreamingDeflate→ replay batch pair), thoughtful async lifecycle handling (flush chaining, queued upload cycles), and broad test coverage across all integration apps. Prior blocking and most minor findings from human and bot reviews are now fixed on head7f92ebe.Why not 5: Two small correctness gaps remain around browser SDK 7.x change-format snapshots and clearing renderer
_dd.replay_stats, plussessionReplaySampleRateis still absent from the customer-facing README table.
Findings
- [Minor] Change-format full snapshot —
has_full_snapshotonly recognizes legacy type-2 records, not browser SDK 7.xRecordType.Change(type 12) whenindex_in_view === 0.- [Minor] Stale
_dd.replay_stats— Clearingsession.has_replaydoes not override renderer-provided_dd.replay_stats, which can survivecombine()on sampled-out sessions.- [Nit] Missing customer docs —
sessionReplaySampleRateis validated and reported in_dd.configurationbut not documented in the README configuration table.
Architectural flow
LoadingsequenceDiagram participant R as Renderer browser-rum participant B as DatadogEventBridge 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 plus view.id RP->>EM: RawReplayEvent EM->>RC: onRecord sampled sessions only RC->>RC: Segment buffer and flush triggers RC->>SD: compressSegment per-session ZLIB stream SD-->>RC: compressed blob RC->>EM: ServerReplayEvent REPLAY track EM->>RBP: post one file per segment RBC->>RBC: read log build multipart form-data RBC->>I: POST segment with merged intake params RC->>EM: replayContext hook enriches view eventsBefore: Renderer rrweb records had no main-process pipeline. Transport only handled standard JSON NDJSON tracks (RUM, spans, profiling).
After:
RendererPipelineingestsrecordbridge events.ReplayCollectionbuffers and compresses them into browser-compatible segments. A dedicated replay batch pair persists and uploads multipart payloads viaappendIntakeParams. A RUM hook injectshas_replayandreplay_statsinto renderer view events once segments are actually sent.Sent by Cursor Automation: electron-sdk reviews
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7f92ebe7f5
ℹ️ 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".
| const session = this.sessionManager.getSession(); | ||
| if (session.status !== 'active' || !this.currentViewId || !this.isReplaySampled(session.id)) { |
There was a problem hiding this comment.
Use record timestamps for replay session attribution
When replay IPC is delayed across an inactivity/session timeout, this uses the current session instead of the session that covered the record's timestamp. SessionManager exposes time-indexed attribution for asynchronous data, but here a record captured before SESSION_EXPIRED can be dropped after expiry or attached to the renewed session, corrupting replay-to-RUM stitching for users returning after a timeout.
Useful? React with 👍 / 👎.
| break; | ||
| } | ||
| case 'record': | ||
| if (!bridgeEvent.view) { |
There was a problem hiding this comment.
Validate replay view IDs before dispatch
For malformed or mismatched bridge messages where view exists but view.id is missing or empty, this check passes and the record is forwarded anyway. ReplayCollection then receives undefined and may append the record to the previous/current segment, so a bad IPC payload can pollute another view's replay instead of being rejected at the boundary.
Useful? React with 👍 / 👎.
| vi.mock('@datadog/browser-core', () => ({ | ||
| dateNow: vi.fn(() => 1234567890), | ||
| ONE_SECOND: 1000, | ||
| })); |
There was a problem hiding this comment.
Mock the timestamp module in the replay producer spec
This new spec replaces @datadog/browser-core with a partial mock, but the producer's batch filename generator imports dateNow from @datadog/js-core/time; meanwhile importing CreationReason through the replay barrel evaluates modules that import other browser-core exports such as performDraw. Under Vitest this partial mock can make the test module fail during import with missing browser-core exports, so mock @datadog/js-core/time instead and avoid replacing browser-core here.
Useful? React with 👍 / 👎.
| */ | ||
| export async function _flushTransport(): Promise<void> { | ||
| await tracing?.flush(); | ||
| await replayCollection?.stop(); |
There was a problem hiding this comment.
Flush replay before the unbounded tracing flush
On graceful shutdown, _flushTransport() is raced against the 5 s fallback timer, but the final replay segment is only stopped after tracing.flush(). If the dd-trace exporter is slow or never invokes its callback, the fallback quit fires before replay is even compressed and posted to the transport, losing exactly the last segment this shutdown path is trying to preserve.
Useful? React with 👍 / 👎.
| this.segment.addRecord(record); | ||
|
|
||
| if (this.segment.estimatedSize > SEGMENT_BYTES_LIMIT) { |
There was a problem hiding this comment.
Split segments before appending an over-limit record
For long views with a large mutation or full-snapshot record, the size check runs only after the record is appended, so the segment written to disk can exceed SEGMENT_BYTES_LIMIT by an entire record. Since that limit is intended to keep replay uploads under the backend object-size cap, near-limit segments can be rejected instead of being split before the new record is added.
Useful? React with 👍 / 👎.
| // session sampled out for replay, or nothing flushed yet — explicitly clear the flag. | ||
| // Otherwise the uploaded RUM view claims a replay exists that Electron never sent. | ||
| return { | ||
| session: { has_replay: false }, |
There was a problem hiding this comment.
Preserve replay flags for buffered segments
For short views or route changes, the renderer can send the last view event before the main process has flushed that view's replay segment, since flushing happens on the 5 s timer, manual flush, or after a later record with a different view id arrives. This branch overwrites the renderer's has_replay with false, and if no later view event is emitted for that old view, Datadog receives a replay segment while the RUM view says no replay exists.
Useful? React with 👍 / 👎.
| void _flushTransport().finally(doQuit); | ||
| }); | ||
|
|
||
| app.on('before-quit', onBeforeQuit); |
There was a problem hiding this comment.
Avoid stacking quit handlers across init calls
If init() is called more than once, this registers multiple before-quit listeners that share the module-level isQuitting flag. On quit, the first listener removes only itself and calls app.quit() after flushing; the remaining listener then prevents the re-entered quit event, sees isQuitting === true, and returns without scheduling doQuit, leaving the app unable to quit.
Useful? React with 👍 / 👎.
|
@codex review |
There was a problem hiding this comment.
PR Review — Score: 4.8 / 5
This is a well-executed Session Replay integration. The follow-up commits since the last review (ff2502c) addressed every remaining concern: change-format has_full_snapshot, _dd.replay_stats clearing, sessionReplaySampleRate in the README config table, view-id validation, pre-append segment splitting, replay-before-tracing shutdown ordering, stacked quit-handler prevention, and the isReplayActive()-based has_replay semantics. I would approve.
Why 4.8: Clear separation between ReplayCollection, Segment, StreamingDeflate, and replay-specific batch producer/consumer; thorough unit and E2E coverage (including privacy masking); transport refactor with appendIntakeParams for correct intake URLs; robust shutdown, corrupt-batch recovery, and bridge validation paths.
Why not 5: Replay segments still use the current session at IPC processing time instead of time-indexed attribution (getTrackedSessionId(record.timestamp)), which ProfilingCollection already does — a delayed record across an inactivity expiry/renewal could get the wrong session.id or be dropped. Customer-facing docs also stop at the config table entry; there is no Session Replay setup section comparable to Renderer Profiling.
Findings
- [Minor] Record-time session attribution —
ReplayCollectionstamps segments withgetSession()at IPC time rather thangetTrackedSessionId(record.timestamp), so delayed records across a session boundary may be dropped or attached to the wrong session. - [Nit] Missing Session Replay customer docs — README lists
sessionReplaySampleRatein the config table but has no feature entry or setup guide (unlike Renderer Profiling).
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 plus view.id
RP->>EM: RawReplayEvent
EM->>RC: onRecord sampled sessions only
RC->>RC: Segment buffer and flush triggers
RC->>SD: compressSegment per-session ZLIB stream
SD-->>RC: compressed blob
RC->>EM: ServerReplayEvent REPLAY track
EM->>RBP: post one file per segment
RBC->>RBC: read log build multipart form-data
RBC->>I: POST segment plus event metadata
RC->>EM: registerReplayContext enriches 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 based on the main process sampling decision.
Sent by Cursor Automation: electron-sdk reviews
| } | ||
|
|
||
| private getSegmentContext(): SegmentContext | undefined { | ||
| const session = this.sessionManager.getSession(); |
There was a problem hiding this comment.
Minor — attribute segments by record timestamp, not IPC arrival time.
ProfilingCollection uses sessionManager.getTrackedSessionId(captureTime) so asynchronously flushed data is attributed to the session that was active when it was captured. Here, getSegmentContext() calls getSession() at IPC processing time.
If replay records are delayed across an inactivity expiry/renewal boundary, records captured before expiry may be dropped (status !== 'active') or stamped with the renewed session's id, corrupting replay-to-RUM stitching.
Consider passing record.timestamp into getSegmentContext() and using getTrackedSessionId(record.timestamp) (with the same sampled-session check) for segment metadata.
| | `env` | `string` | No | — | Application environment | | ||
| | `version` | `string` | No | — | Application version | | ||
| | `sessionSampleRate` | `number` | No | `100` | Percentage of sessions to collect (0–100). `0` collects no sessions; `100` collects all sessions. | | ||
| | `sessionReplaySampleRate` | `number` | No | `0` | Percentage of sampled sessions that record session replay (0–100). `0` disables renderer session replay. Applied as a child of `sessionSampleRate`. | |
There was a problem hiding this comment.
Nit — add a Session Replay setup section.
sessionReplaySampleRate is now documented in the config table (good), but unlike profilingSampleRate there is no linked "Session Replay" section under Available Features or a setup guide explaining that customers must also initialize @datadog/browser-rum in the renderer with session replay enabled. A short section mirroring Renderer Profiling would close the loop for adopters.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ff2502c5dc
ℹ️ 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".
| // - false positive: the renderer stamped has_replay but Electron sampled the session out; | ||
| // - false negative: a segment is buffered but not yet flushed (short views / route changes) | ||
| // when the view event is sent, which a flush-state-based flag would wrongly report as no replay. | ||
| if (!isReplayActive()) { |
There was a problem hiding this comment.
Attribute replay state using the event session
When a renderer view event is delivered after the main session has expired or renewed, the session hook still attributes it by the event's date, but this replay hook checks the current session instead. In that case an old view can be stamped with the new session's replay sampling decision, producing has_replay: false/zero stats for a replayed session or a false positive for a non-replayed one; resolve the replay decision/stats for the event's start time/session, as the profiling hook does.
Useful? React with 👍 / 👎.
| trackResources: true, | ||
| trackLongTasks: true, | ||
| trackUserInteractions: true, | ||
| sessionReplaySampleRate: 100, |
There was a problem hiding this comment.
Restore profiling sampling in the bridge fixture
This bridge-window fixture is also used by e2e/scenarios/profiling.scenario.ts, which opens the bridge window and waits for a profiling multipart upload. Removing profilingSampleRate: 100 from this Browser RUM init leaves browser-side profiling disabled for that window, so the existing profiling E2E will time out waiting for a profile even though the Electron SDK still advertises the profiles capability.
Useful? React with 👍 / 👎.
| const session = this.sessionManager.getSession(); | ||
| if (session.status !== 'active' || !this.currentViewId || !this.isReplaySampled(session.id)) { |
There was a problem hiding this comment.
Build replay segments from the record's session
When replay records are delayed across a session expiry or renewal, this uses whichever session is active when IPC is handled, even though the record timestamp belongs to the previous session. RUM events for that same timestamp are attributed through the session history, so the replay segment can be uploaded under the wrong session (or dropped if the current session is expired) and won't link to the view it recorded; resolve the tracked session from the record timestamp before creating the segment context.
Useful? React with 👍 / 👎.
| // dd-trace flush, whose exporter can be slow or never call back. If tracing went first and hung, | ||
| // the before-quit fallback timer could fire before the last replay segment was even produced — | ||
| // losing exactly what this shutdown path exists to preserve. | ||
| await replayCollection?.stop(); |
There was a problem hiding this comment.
Flush replay batches before waiting on tracing
In the before-quit path where dd-trace flush is slow or never returns, this only waits for replay compression and eventManager.notify; the actual replay file write/upload is not awaited until transport.flush() after tracing.flush(). If the 5s fallback fires while tracing is still pending, the app can quit with the final replay segment still queued in the batch producer, so the last segment this shutdown path is meant to preserve may be lost; flush the replay transport before awaiting tracing.
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