Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/browser/replay/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default {
enabled: false, // Whether recording is enabled
autoStart: true, // Start recording automatically when Rollbar initializes
maxSeconds: 300, // Maximum recording duration in seconds
postDuration: 5, // Duration of events to include after a post is triggered, in seconds

baseSamplingRatio: 1.0, // Used by triggers that don't specify a sampling ratio
triggers: [
Expand Down
46 changes: 30 additions & 16 deletions src/browser/replay/recorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export default class Recorder {
}

/**
* Exports the recording span with all recorded events.
* Exports the recording span with all recorded events or events after a specific point.
*
* This method takes the recorder's stored events, creates a new span with the
* provided tracing context, attaches all events with their timestamps as span
Expand All @@ -86,14 +86,14 @@ export default class Recorder {
*
* @param {Object} tracing - The tracing system instance to create spans
* @param {Object} attributes - Attributes to add to the span
* (e.g., rollbar.replay.id, rollbar.occurrence.uuid)
* (e.g., rollbar.replay.id, rollbar.occurrence.uuid, rollbar.replay.type)
* @param {number} [afterCount=0] - If provided, only export events after this count (for leading replay)
*/
exportRecordingSpan(tracing, attributes = {}) {
const events = this._collectEvents();
exportRecordingSpan(tracing, attributes = {}, afterCount = 0) {
const events = this._collectEvents(afterCount);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe startIndex or startPosition?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Since this is changing completely, can we leave it like this? So I can merge this and move on to the next PRs!

Then we circle back.


Comment thread
matux marked this conversation as resolved.
if (events.length < 3) {
// TODO(matux): improve how we consider a recording valid
throw new Error('Replay recording cannot have less than 3 events');
if (events.length === 0) {
throw new Error('Replay recording has no events');
}

const recordingSpan = tracing.startSpan('rrweb-replay-recording', {});
Expand Down Expand Up @@ -179,20 +179,34 @@ export default class Recorder {
this._isReady = false;
}

_collectEvents() {
const events = this._events.previous.concat(this._events.current);
_collectEvents(afterCount = 0) {
const allEvents = this._events.previous.concat(this._events.current);

// Helps the application correctly align playback by adding a noop event
// to the end of the recording.
events.push({
timestamp: Date.now(),
type: EventType.Custom,
data: { tag: 'replay.end', payload: {} },
});
const events = afterCount > 0 ? allEvents.slice(afterCount) : allEvents;

if (events.length > 0) {
// Helps the application correctly align playback by adding a noop event
// to the end of the recording.
events.push({
timestamp: Date.now(),
type: EventType.Custom,
data: { tag: 'replay.end', payload: {} },
});
}
Comment thread
matux marked this conversation as resolved.

return events;
}

/**
* Gets the current count of events in the buffers.
* This represents a marker for where trailing events end.
*
* @returns {number} The total number of events currently stored
*/
getCurrentEventCount() {
return this._events.previous.length + this._events.current.length;
}
Comment on lines +200 to +208

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We use the event count as a boundary marker rather than tracking the specific event. This has a fatal flaw, though: If during those N post-seconds we get a checkout from rrweb, we rotate the buffer and this number becomes invalid (out of bounds).

I have a fix for this, but I'll send it in a subsequent PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

👍 Looking forward to the fix. We had talked about tracking both the buffer and the index in the buffer.


_logEvent(event, isCheckout) {
logger.log(
`Recorder: ${isCheckout ? 'checkout' : ''} event\n`,
Expand Down
161 changes: 160 additions & 1 deletion src/browser/replay/replayManager.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import id from '../../tracing/id.js';
import logger from '../../logger.js';

/**
* Enum for tracking the status of trailing replay sends.
* Used to coordinate between trailing and leading replay captures.
*/
const TrailingStatus = Object.freeze({
PENDING: 'pending', // Trailing not yet sent
SENT: 'sent', // Trailing successfully sent
FAILED: 'failed', // Trailing failed to send
});

/**
* ReplayManager - Manages the mapping between error occurrences and their associated
* session recordings. This class handles the coordination between when recordings
Expand All @@ -12,6 +22,8 @@ export default class ReplayManager {
_api;
_tracing;
_telemeter;
_pendingLeading;
_trailingStatus;

/**
* Creates a new ReplayManager instance
Expand Down Expand Up @@ -39,6 +51,8 @@ export default class ReplayManager {
this._api = api;
this._tracing = tracing;
this._telemeter = telemeter;
this._pendingLeading = new Map();
this._trailingStatus = new Map();
}

/**
Expand Down Expand Up @@ -67,6 +81,142 @@ export default class ReplayManager {

const payload = this._tracing.exporter.toPayload();
this._map.set(replayId, payload);

const leadingSeconds = this._recorder.options?.postDuration || 0;
if (leadingSeconds > 0 && this._recorder.isRecording) {
this._scheduleLeadingCapture(replayId, occurrenceUuid, leadingSeconds);
}
}

/**
* Schedules the capture of leading replay events after a delay.
*
* @param {string} replayId - The replay ID
* @param {string} occurrenceUuid - The occurrence UUID
* @param {number} seconds - Number of seconds to wait before capturing
* @private
*/
_scheduleLeadingCapture(replayId, occurrenceUuid, seconds) {
const trailingEndCount = this._recorder.getCurrentEventCount();

this._trailingStatus.set(replayId, TrailingStatus.PENDING);

const timerId = setTimeout(async () => {
try {
await this._exportLeadingSpansAndAddPayload(
replayId,
occurrenceUuid,
trailingEndCount,
);
this._sendOrDiscardLeadingReplay(replayId);
} catch (error) {
logger.error('Error during leading replay processing:', error);
}
}, seconds * 1000);

this._pendingLeading.set(replayId, {
timerId,
occurrenceUuid,
trailingEndCount,
leadingReady: false,
});
}

/**
* Exports leading replay spans and adds the payload to pending context.
* Similar to _exportSpansAndAddTracingPayload but for leading events.
*
* @param {string} replayId - The replay ID
* @param {string} occurrenceUuid - The occurrence UUID
* @param {number} trailingEndCount - Event count marking end of trailing events
* @private
*/
async _exportLeadingSpansAndAddPayload(
replayId,
occurrenceUuid,
trailingEndCount,
) {
const pendingContext = this._pendingLeading.get(replayId);

if (!pendingContext) {
// Already cleaned up, possibly due to discard
return;
}

try {
this._recorder.exportRecordingSpan(
this._tracing,
{
'rollbar.replay.id': replayId,
'rollbar.occurrence.uuid': occurrenceUuid,
},
trailingEndCount,
);
} catch (error) {
logger.error('Error exporting leading recording span:', error);
this._discardLeadingCapture(replayId);
return;
}

this._telemeter?.exportTelemetrySpan({
'rollbar.replay.id': replayId,
});

const leadingPayload = this._tracing.exporter.toPayload();
pendingContext.leadingReady = true;
pendingContext.leadingPayload = leadingPayload;
this._pendingLeading.set(replayId, pendingContext);
}

/**
* Sends or discards leading replay based on trailing replay status.
*
* @param {string} replayId - The replay ID
* @private
*/
async _sendOrDiscardLeadingReplay(replayId) {
const trailingStatus = this._trailingStatus.get(replayId);
const pendingContext = this._pendingLeading.get(replayId);

if (!pendingContext?.leadingReady || !pendingContext?.leadingPayload) {
return;
}

switch (trailingStatus) {
case TrailingStatus.SENT:
try {
await this._api.postSpans(pendingContext.leadingPayload, {
'X-Rollbar-Replay-Id': replayId,
});
} catch (error) {
logger.error('Failed to send leading replay:', error);
}
this._discardLeadingCapture(replayId);
break;

case TrailingStatus.FAILED:
this._discardLeadingCapture(replayId);
break;

case TrailingStatus.PENDING:
default:
break;
}
}

/**
* Discards all state related to leading capture for a replay.
*
* @param {string} replayId - The replay ID to discard
* @private
*/
_discardLeadingCapture(replayId) {
const pendingContext = this._pendingLeading.get(replayId);
if (pendingContext?.timerId) {
clearTimeout(pendingContext.timerId);
}
this._pendingLeading.delete(replayId);
this._trailingStatus.delete(replayId);
}

/**
Expand All @@ -80,7 +230,9 @@ export default class ReplayManager {
*/
add(replayId, occurrenceUuid) {
if (!this._recorder.isReady) {
logger.warn('ReplayManager.add: Recorder is not ready, cannot export replay');
logger.warn(
'ReplayManager.add: Recorder is not ready, cannot export replay',
);
return null;
}
replayId = replayId || id.gen(8);
Expand Down Expand Up @@ -125,6 +277,9 @@ export default class ReplayManager {
}

await this._api.postSpans(payload, { 'X-Rollbar-Replay-Id': replayId });

this._trailingStatus.set(replayId, TrailingStatus.SENT);
await this._sendOrDiscardLeadingReplay(replayId);
}

/**
Expand All @@ -140,6 +295,10 @@ export default class ReplayManager {
return false;
}

this._trailingStatus.set(replayId, TrailingStatus.FAILED);

this._discardLeadingCapture(replayId);

if (!this._map.has(replayId)) {
logger.error(
`ReplayManager.discard: No replay found for replayId: ${replayId}`,
Expand Down
4 changes: 2 additions & 2 deletions test/browser.replay.recorder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -406,13 +406,13 @@ describe('Recorder', function () {
recorder.exportRecordingSpan(mockTracing, {
'rollbar.replay.id': testReplayId,
});
}).to.throw('Replay recording cannot have less than 3 events');
}).to.throw('Replay recording has no events');

expect(mockTracing.startSpan.called).to.be.false;
expect(mockTracing.exporter.toPayload.called).to.be.false;
});

it('should handle less than 2 events (invalid recording)', function () {
it.skip('should handle less than 2 events (invalid recording)', function () {
const recorder = new Recorder({}, recordFnStub);
recorder.start();

Expand Down
15 changes: 12 additions & 3 deletions test/replay/unit/replayManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ describe('ReplayManager', function () {

const expectedPayload = [{ id: 'span1' }, { id: 'span2' }];

await replayManager._exportSpansAndAddTracingPayload(replayId, occurrenceUuid);
await replayManager._exportSpansAndAddTracingPayload(
replayId,
occurrenceUuid,
);

expect(mockTelemeter.exportTelemetrySpan.calledOnce).to.be.true;
expect(
Expand Down Expand Up @@ -145,7 +148,10 @@ describe('ReplayManager', function () {
const loggerSpy = sinon.spy(logger, 'error');
const replayId = '1234567890abcdef';
const occurrenceUuid = 'test-uuid';
await replayManager._exportSpansAndAddTracingPayload(replayId, occurrenceUuid);
await replayManager._exportSpansAndAddTracingPayload(
replayId,
occurrenceUuid,
);

expect(mockRecorder.exportRecordingSpan.called).to.be.true;
expect(
Expand Down Expand Up @@ -177,7 +183,10 @@ describe('ReplayManager', function () {
const occurrenceUuid = 'test-uuid';

const expectedPayload = [{ id: 'span1' }, { id: 'span2' }];
await replayManager._exportSpansAndAddTracingPayload(replayId, occurrenceUuid);
await replayManager._exportSpansAndAddTracingPayload(
replayId,
occurrenceUuid,
);
expect(mockRecorder.exportRecordingSpan.called).to.be.true;
expect(
mockRecorder.exportRecordingSpan.calledWith(mockTracing, {
Expand Down
Loading