Skip to content

Commit b3aeddc

Browse files
authored
[Feature] Post-trigger Replay Capture (aka. Leading Replay) (#1341)
1 parent 5403bc1 commit b3aeddc

16 files changed

+1728
-373
lines changed

src/browser/replay/defaults.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default {
66
enabled: false, // Whether recording is enabled
77
autoStart: true, // Start recording automatically when Rollbar initializes
88
maxSeconds: 300, // Maximum recording duration in seconds
9+
postDuration: 5, // Duration of events to include after a post is triggered, in seconds
910

1011
baseSamplingRatio: 1.0, // Used by triggers that don't specify a sampling ratio
1112
triggers: [

src/browser/replay/recorder.d.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
import type { Rollbar } from '../../../index.js';
22

3-
declare const Recorder: Rollbar.RecorderType;
3+
/** A point-in-time cursor into the active Recorder's two-slot ring buffer. */
4+
export type BufferCursor = {
5+
/** Index (0|1) of the active buffer's slot at snapshot time. */
6+
slot: 0 | 1;
7+
/**
8+
* Zero-based index of the last event at snapshot time; exclusive boundary.
9+
* May be -1 when empty.
10+
*/
11+
offset: number;
12+
};
13+
14+
export interface Recorder extends Rollbar.RecorderType {
15+
bufferCursor(): BufferCursor;
16+
exportRecordingSpan(
17+
tracing: any,
18+
attributes?: Record<string, any>,
19+
cursor?: BufferCursor,
20+
): void;
21+
}
22+
23+
declare const Recorder: Recorder;
424

525
export default Recorder;

src/browser/replay/recorder.js

Lines changed: 132 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,24 @@ import { EventType } from '@rrweb/types';
44
import hrtime from '../../tracing/hrtime.js';
55
import logger from '../../logger.js';
66

7+
/** @typedef {import('./recorder.js').BufferCursor} BufferCursor */
8+
79
export 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

Comments
 (0)