Skip to content

[RUM-15529] Integrate Session Replay#131

Open
cdn34dd wants to merge 14 commits into
mainfrom
carlosnogueira/RUM-15529/integrate-session-replay
Open

[RUM-15529] Integrate Session Replay#131
cdn34dd wants to merge 14 commits into
mainfrom
carlosnogueira/RUM-15529/integrate-session-replay

Conversation

@cdn34dd

@cdn34dd cdn34dd commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Motivation

Implement session replay for the Electron SDK. Browser RUM SDK records DOM activity in renderer processes and sends those records through the DatadogEventBridge to 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)

BatchProducer and BatchConsumer are now abstract base classes, moving the concrete JSON-NDJSON / JSON-array-POST logic into GenericBatchProducer and GenericBatchConsumer under src/transport/batch/generic/. A new BatchFactory owns 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 — buffers BrowserRecord objects 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 for RawReplayEvents 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 emits ServerReplayEvents for the transport layer.
  • src/transport/batch/replay/ReplayBatchProducer.ts — writes one atomic file per compressed segment (JSON metadata line + base64 compressed body, .tmp.log rename 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

Assembly now accepts a getViewReplayStats callback and injects session.has_replay: true and _dd.replay_stats into outgoing RUM view events, so the DD UI shows the replay indicator on views that have recorded data.

Bridge / IPC

  • BridgeHandler's CONFIG_CHANNEL handler switched from ipcMain.on + sendSync to ipcMain.handle + invoke, decoupling the preload's config fetch from SDK init order.
  • dd-trace's Electron preload is patched (Yarn patch) to add "records" to getCapabilities() — without this the browser RUM SDK never emits BrowserRecord events — and to replace the blocking sendSync config 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

  • Tested locally (playground)
  • Added unit tests for this change.
  • Added e2e/integration tests for this change.
  • Updated related documentation.

@datadog-official

This comment has been minimized.

@cdn34dd
cdn34dd force-pushed the carlosnogueira/RUM-15529/integrate-session-replay branch from 47d9edb to 3783730 Compare June 3, 2026 10:21
@cdn34dd

cdn34dd commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread src/transport/batch/replay/ReplayBatchProducer.ts Outdated
Comment thread .yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch Outdated
@cdn34dd
cdn34dd marked this pull request as ready for review June 3, 2026 11:18
@cdn34dd
cdn34dd requested a review from a team as a code owner June 3, 2026 11:18

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread src/domain/replay/ReplayCollection.ts Outdated
Comment thread .yarn/patches/dd-trace-npm-5.103.0-bcd76a72f0.patch Outdated
Comment thread src/index.ts Outdated
Comment thread src/index.ts Outdated
Comment thread src/assembly/Assembly.ts Outdated
Comment thread src/transport/Transport.spec.ts Outdated
Comment thread src/domain/replay/Segment.ts
Comment thread src/event/event.types.ts Outdated
Comment thread src/assembly/Assembly.ts Outdated
Comment on lines +36 to +37
private segmentIndexPerView = new Map<string, number>();
private viewReplayStats = new Map<string, ViewReplayStats>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

❗ warning: ‏We should prevent these maps to grow indefinitely

Comment on lines +61 to +65
if (event.lifecycle === LifecycleKind.SESSION_EXPIRED) {
this.flush('session_renew');
} else if (event.lifecycle === LifecycleKind.SESSION_RENEW) {
this.nextCreationReason = 'session_renew';
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

That seems a bit weird to use init here and also on addRecord 🤔
What was your reference implemenation?

@cdn34dd
cdn34dd force-pushed the carlosnogueira/RUM-15529/integrate-session-replay branch 2 times, most recently from 926b91c to 5182d1b Compare June 11, 2026 21:45
Comment thread package.json Outdated
Comment thread src/index.ts Outdated
Comment thread src/index.ts Outdated
Comment thread src/index.ts Outdated
Comment thread src/domain/replay/replayContext.ts Outdated
Comment thread e2e/app/src/bridge-window.ts Outdated
Comment thread e2e/scenarios/replay.scenario.ts
Comment thread src/event/event.types.ts Outdated
track: typeof EventTrack.REPLAY;
format: typeof EventFormat.REPLAY;
data: unknown;
view?: { id: string };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment on lines +61 to +65
if (event.lifecycle === LifecycleKind.SESSION_EXPIRED) {
this.flush('session_renew');
} else if (event.lifecycle === LifecycleKind.SESSION_RENEW) {
this.nextCreationReason = 'session_renew';
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

That seems a bit weird to use init here and also on addRecord 🤔
What was your reference implemenation?

Comment thread src/domain/replay/ReplayCollection.ts Outdated
@sbarrio
sbarrio requested a review from mormubis June 26, 2026 08:49
@cdn34dd
cdn34dd force-pushed the carlosnogueira/RUM-15529/integrate-session-replay branch from 5182d1b to 26064d1 Compare July 20, 2026 10:35

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread src/transport/batch/replay/ReplayBatchConsumer.ts Outdated
Comment thread src/transport/batch/replay/ReplayBatchConsumer.ts Outdated
Comment thread src/index.ts
Comment thread src/domain/replay/replayContext.ts Outdated
Comment thread src/domain/replay/Segment.ts Outdated
}
// 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@cdn34dd

cdn34dd commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 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=electron mangles query params and breaks the intended ddsource=browser convention.
  • [Minor] Corrupt replay batch recoveryJSON.parse in ReplayBatchConsumer.buildRequest is unguarded; a truncated recovered .log can abort the upload cycle indefinitely (profile consumer drops malformed files).
  • [Minor] Quit re-entrancy guard — The done flag is scoped per before-quit invocation, so a second quit during an in-flight flush can call app.quit() before the first upload finishes.
  • [Minor] Stale has_replay on view events — Returning SKIPPED when no Electron replay stats exist leaves renderer-provided session.has_replay / _dd.replay_stats on the combined event.
  • [Minor] Segment size estimateestimatedSize uses 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 timestamp produces NaN segment bounds instead of being dropped at the IPC boundary.
  • [Nit] Missing customer docssessionReplaySampleRate is 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
Loading

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.

Open in Web View Automation 

Sent by Cursor Automation: electron-sdk reviews

Comment thread src/transport/batch/replay/ReplayBatchConsumer.ts Outdated
Comment thread src/transport/batch/replay/ReplayBatchConsumer.ts Outdated
Comment thread src/index.ts
Comment thread src/domain/replay/replayContext.ts Outdated
Comment thread src/domain/replay/Segment.ts Outdated
Comment thread src/assembly/RendererPipeline.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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()}`, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +22 to +25
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src/index.ts
};

setTimeout(doQuit, 5000);
void _flushTransport().finally(doQuit);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src/domain/replay/Segment.ts Outdated
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src/assembly/RendererPipeline.ts
Comment thread src/config.ts
version: validateOptionalString(initConfig.version),
proxy,
sessionSampleRate,
sessionReplaySampleRate,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src/domain/replay/Segment.ts Outdated
}

addRecord(record: BrowserRecord): void {
this._estimatedSize += JSON.stringify(record).length;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

cdn34dd added 11 commits July 22, 2026 16:31
…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
@cdn34dd
cdn34dd force-pushed the carlosnogueira/RUM-15529/integrate-session-replay branch from a8d6ac2 to 65d3268 Compare July 22, 2026 15:39
@cdn34dd
cdn34dd force-pushed the carlosnogueira/RUM-15529/integrate-session-replay branch from 65d3268 to 7f92ebe Compare July 22, 2026 15:45
@cdn34dd

cdn34dd commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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, and has_replay false-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 (ReplayCollectionSegmentStreamingDeflate → 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 head 7f92ebe.

Why not 5: Two small correctness gaps remain around browser SDK 7.x change-format snapshots and clearing renderer _dd.replay_stats, plus sessionReplaySampleRate is still absent from the customer-facing README table.


Findings

  • [Minor] Change-format full snapshothas_full_snapshot only recognizes legacy type-2 records, not browser SDK 7.x RecordType.Change (type 12) when index_in_view === 0.
  • [Minor] Stale _dd.replay_stats — Clearing session.has_replay does not override renderer-provided _dd.replay_stats, which can survive combine() on sampled-out sessions.
  • [Nit] Missing customer docssessionReplaySampleRate is validated and reported in _dd.configuration but not documented in the README configuration table.

Architectural flow

sequenceDiagram
    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 events
Loading

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 and compresses them into browser-compatible segments. A dedicated replay batch pair persists and uploads multipart payloads via appendIntakeParams. A RUM hook injects has_replay and replay_stats into renderer view events once segments are actually sent.

Open in Web View Automation 

Sent by Cursor Automation: electron-sdk reviews

Comment thread src/domain/replay/Segment.ts Outdated
Comment thread src/domain/replay/replayContext.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +126 to +127
const session = this.sessionManager.getSession();
if (session.status !== 'active' || !this.currentViewId || !this.isReplaySampled(session.id)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src/assembly/RendererPipeline.ts Outdated
break;
}
case 'record':
if (!bridgeEvent.view) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +11 to +14
vi.mock('@datadog/browser-core', () => ({
dateNow: vi.fn(() => 1234567890),
ONE_SECOND: 1000,
}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src/index.ts
*/
export async function _flushTransport(): Promise<void> {
await tracing?.flush();
await replayCollection?.stop();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src/domain/replay/ReplayCollection.ts Outdated
Comment on lines +118 to +120
this.segment.addRecord(record);

if (this.segment.estimatedSize > SEGMENT_BYTES_LIMIT) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src/config.ts
// 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 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src/index.ts Outdated
void _flushTransport().finally(doQuit);
});

app.on('before-quit', onBeforeQuit);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge 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 👍 / 👎.

@cdn34dd

cdn34dd commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 attributionReplayCollection stamps segments with getSession() at IPC time rather than getTrackedSessionId(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 sessionReplaySampleRate in 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
Loading

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.

Open in Web View Automation 

Sent by Cursor Automation: electron-sdk reviews

}

private getSegmentContext(): SegmentContext | undefined {
const session = this.sessionManager.getSession();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread README.md
| `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`. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +155 to +156
const session = this.sessionManager.getSession();
if (session.status !== 'active' || !this.currentViewId || !this.isReplaySampled(session.id)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment thread src/index.ts
// 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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.

2 participants