@@ -4,15 +4,24 @@ import { EventType } from '@rrweb/types';
44import hrtime from '../../tracing/hrtime.js' ;
55import logger from '../../logger.js' ;
66
7+ /** @typedef {import('./recorder.js').BufferCursor } BufferCursor */
8+
79export default class Recorder {
810 _options ;
911 _rrwebOptions ;
12+
13+ _isReady = false ;
1014 _stopFn = null ;
1115 _recordFn ;
12- _events = {
13- previous : [ ] ,
14- current : [ ] ,
15- } ;
16+
17+ /** A two-slot ring buffer for storing events. */
18+ _buffers = [ [ ] , [ ] ] ;
19+ /** Active slot index (0|1). Stores new events until next checkout. */
20+ _currentSlot = 0 ;
21+ /** Index of the finalized inactive slot (0|1). Frozen until next checkout. */
22+ get _previousSlot ( ) {
23+ return this . _currentSlot ^ 1 ;
24+ }
1625
1726 /**
1827 * Creates a new Recorder instance for capturing DOM events
@@ -27,7 +36,6 @@ export default class Recorder {
2736
2837 this . options = options ;
2938 this . _recordFn = recordFn ;
30- this . _isReady = false ;
3139 }
3240
3341 get isRecording ( ) {
@@ -52,6 +60,7 @@ export default class Recorder {
5260 enabled,
5361 autoStart,
5462 maxSeconds,
63+ postDuration,
5564 triggers,
5665 debug,
5766
@@ -62,38 +71,69 @@ export default class Recorder {
6271 // rrweb options
6372 ...rrwebOptions
6473 } = newOptions ;
65- this . _options = { enabled, autoStart, maxSeconds, triggers, debug } ;
74+
75+ this . _options = {
76+ enabled,
77+ autoStart,
78+ maxSeconds,
79+ postDuration,
80+ triggers,
81+ debug,
82+ } ;
83+
6684 this . _rrwebOptions = rrwebOptions ;
6785
6886 if ( this . isRecording && newOptions . enabled === false ) {
6987 this . stop ( ) ;
7088 }
7189 }
7290
91+ /**
92+ * Calculates the checkout interval in milliseconds.
93+ *
94+ * Recording may span up to two checkout intervals, so the interval is set
95+ * to half of maxSeconds to ensure coverage.
96+ *
97+ * @returns {number } Checkout interval in milliseconds
98+ */
7399 checkoutEveryNms ( ) {
74- // Recording may be up to two checkout intervals, therefore the checkout
75- // interval is set to half of the maxSeconds.
76100 return ( ( this . options . maxSeconds || 10 ) * 1000 ) / 2 ;
77101 }
78102
79103 /**
80- * Exports the recording span with all recorded events.
104+ * Returns a point-in-time cursor for the active buffer.
105+ *
106+ * Used to capture a stable cursor that survives a single checkout.
107+ *
108+ * @remarks
81109 *
82- * This method takes the recorder's stored events, creates a new span with the
83- * provided tracing context, attaches all events with their timestamps as span
84- * events, and exports the span to the tracing exporter. This is a side-effect
85- * function that doesn't return anything - the span is exported internally.
110+ * While offset can be `-1` if the buffer is empty, this cannot occur when
111+ * `_isReady` is `true`. The emit callback always pushes the triggering event
112+ * after any buffer reset, ensuring the active buffer has at least one event.
113+ *
114+ * @returns {BufferCursor } Buffer index and event offset.
115+ */
116+ bufferCursor ( ) {
117+ return {
118+ slot : this . _currentSlot ,
119+ offset : this . _buffers [ this . _currentSlot ] . length - 1 ,
120+ } ;
121+ }
122+
123+ /**
124+ * Exports the recording span with all recorded events or events after a cursor.
86125 *
87126 * @param {Object } tracing - The tracing system instance to create spans
88- * @param {Object } attributes - Attributes to add to the span
89- * (e.g., rollbar.replay.id, rollbar.occurrence.uuid)
127+ * @param {Object } attributes - Span attributes (rollbar.replay.id, etc.)
128+ * @param { BufferCursor } [cursor] - Cursor position to start from (exclusive), or all if not provided
90129 */
91- exportRecordingSpan ( tracing , attributes = { } ) {
92- const events = this . _collectEvents ( ) ;
130+ exportRecordingSpan ( tracing , attributes = { } , cursor ) {
131+ const events = cursor
132+ ? this . _collectEventsFromCursor ( cursor )
133+ : this . _collectAll ( ) ;
93134
94- if ( events . length < 3 ) {
95- // TODO(matux): improve how we consider a recording valid
96- throw new Error ( 'Replay recording cannot have less than 3 events' ) ;
135+ if ( events . length === 0 ) {
136+ throw new Error ( 'Replay recording has no events' ) ;
97137 }
98138
99139 const recordingSpan = tracing . startSpan ( 'rrweb-replay-recording' , { } ) ;
@@ -135,16 +175,16 @@ export default class Recorder {
135175 if ( ! this . _isReady && event . type === EventType . FullSnapshot ) {
136176 this . _isReady = true ;
137177 }
178+
138179 if ( this . options . debug ?. logEmits ) {
139- this . _logEvent ( event , isCheckout ) ;
180+ Recorder . _logEvent ( event , isCheckout ) ;
140181 }
141182
142183 if ( isCheckout && event . type === EventType . Meta ) {
143- this . _events . previous = this . _events . current ;
144- this . _events . current = [ ] ;
184+ this . _buffers [ ( this . _currentSlot = this . _previousSlot ) ] = [ ] ;
145185 }
146186
147- this . _events . current . push ( event ) ;
187+ this . _buffers [ this . _currentSlot ] . push ( event ) ;
148188 } ,
149189 checkoutEveryNms : this . checkoutEveryNms ( ) ,
150190 errorHandler : ( error ) => {
@@ -172,28 +212,82 @@ export default class Recorder {
172212 }
173213
174214 clear ( ) {
175- this . _events = {
176- previous : [ ] ,
177- current : [ ] ,
178- } ;
215+ this . _buffers = [ [ ] , [ ] ] ;
216+ this . _currentSlot = 0 ;
179217 this . _isReady = false ;
180218 }
181219
182- _collectEvents ( ) {
183- const events = this . _events . previous . concat ( this . _events . current ) ;
220+ /**
221+ * Collects all events from both buffers.
222+ *
223+ * @returns {Array } All events with replay.end marker
224+ * @private
225+ */
226+ _collectAll ( ) {
227+ const previousEvents = this . _buffers [ this . _previousSlot ] ;
228+ const currentEvents = this . _buffers [ this . _currentSlot ] ;
229+ const allEvents = previousEvents . concat ( currentEvents ) ;
230+
231+ if ( allEvents . length > 0 ) {
232+ allEvents . push ( Recorder . _replayEndEvent ( ) ) ;
233+ }
234+
235+ return allEvents ;
236+ }
237+
238+ /**
239+ * Collects events after a cursor position.
240+ *
241+ * @param {BufferCursor } cursor - Cursor position to collect from
242+ * @returns {Array } Events after cursor with replay.end marker
243+ * @private
244+ */
245+ _collectEventsFromCursor ( cursor ) {
246+ const capturedBuffer = this . _buffers [ cursor . slot ] ?? [ ] ;
247+ const head = capturedBuffer . slice ( Math . max ( 0 , cursor . offset + 1 ) ) ;
248+ const tail =
249+ cursor . slot === this . _currentSlot ? [ ] : this . _buffers [ this . _currentSlot ] ;
184250
185- // Helps the application correctly align playback by adding a noop event
186- // to the end of the recording.
187- events . push ( {
188- timestamp : Date . now ( ) ,
189- type : EventType . Custom ,
190- data : { tag : 'replay.end' , payload : { } } ,
191- } ) ;
251+ const events = head . concat ( tail ) ;
252+
253+ if ( cursor . slot !== this . _currentSlot && head . length === 0 ) {
254+ logger . warn (
255+ 'Leading replay: captured buffer cleared by multiple checkouts' ,
256+ ) ;
257+ }
258+
259+ if ( events . length > 0 ) {
260+ events . push ( Recorder . _replayEndEvent ( ) ) ;
261+ }
192262
193263 return events ;
194264 }
195265
196- _logEvent ( event , isCheckout ) {
266+ /**
267+ * Creates a replay.end noop marker event.
268+ *
269+ * Helps the application correctly align playback when added at the end of
270+ * the recording.
271+ *
272+ * @returns {Object } replay.end event
273+ * @private
274+ */
275+ static _replayEndEvent ( ) {
276+ return {
277+ type : EventType . Custom ,
278+ timestamp : Date . now ( ) ,
279+ data : { tag : 'replay.end' , payload : { } } ,
280+ } ;
281+ }
282+
283+ /**
284+ * Logs an event for debugging purposes.
285+ *
286+ * @param {Object } event - The event to log
287+ * @param {boolean } isCheckout - Whether this is a checkout event
288+ * @private
289+ */
290+ static _logEvent ( event , isCheckout ) {
197291 logger . log (
198292 `Recorder: ${ isCheckout ? 'checkout' : '' } event\n` ,
199293 ( ( e ) => {
0 commit comments