Skip to content

Commit b99de9b

Browse files
authored
Fix Critical Memory Leak in Session Replay and Refactor for Better Error Handling (#1328)
1 parent b766a6e commit b99de9b

File tree

10 files changed

+992
-414
lines changed

10 files changed

+992
-414
lines changed

src/browser/replay/recorder.js

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -68,39 +68,36 @@ export default class Recorder {
6868
checkoutEveryNms() {
6969
// Recording may be up to two checkout intervals, therefore the checkout
7070
// interval is set to half of the maxSeconds.
71-
return (this.options.maxSeconds || 10) * 1000 / 2;
71+
return ((this.options.maxSeconds || 10) * 1000) / 2;
7272
}
7373

7474
/**
75-
* Converts recorded events into a formatted payload ready for transport.
75+
* Exports the recording span with all recorded events.
7676
*
7777
* This method takes the recorder's stored events, creates a new span with the
7878
* provided tracing context, attaches all events with their timestamps as span
79-
* events, and then returns a payload ready for transport to the server.
79+
* events, and exports the span to the tracing exporter. This is a side-effect
80+
* function that doesn't return anything - the span is exported internally.
8081
*
8182
* @param {Object} tracing - The tracing system instance to create spans
82-
* @param {string} replayId - Unique identifier to associate with this replay recording
83-
* @returns {Object|null} A formatted payload containing spans data in OTLP format, or null if no events exist
83+
* @param {Object} attributes - Attributes to add to the span
84+
* (e.g., rollbar.replay.id, rollbar.occurrence.uuid)
8485
*/
85-
dump(tracing, replayId, occurrenceUuid) {
86-
const events = this._events.previous.concat(this._events.current);
86+
exportRecordingSpan(tracing, attributes = {}) {
87+
const events = this._collectEvents();
8788

88-
if (events.length < 2) {
89-
logger.error('Replay recording cannot have less than 2 events');
90-
return null;
89+
if (events.length < 3) {
90+
// TODO(matux): improve how we consider a recording valid
91+
throw new Error('Replay recording cannot have less than 3 events');
9192
}
9293

9394
const recordingSpan = tracing.startSpan('rrweb-replay-recording', {});
9495

9596
recordingSpan.setAttributes({
9697
...(tracing.session?.attributes ?? {}),
97-
'rollbar.replay.id': replayId
98+
...attributes,
9899
});
99100

100-
if (occurrenceUuid) {
101-
recordingSpan.setAttribute('rollbar.occurrence.uuid', occurrenceUuid);
102-
}
103-
104101
const earliestEvent = events.reduce((earliestEvent, event) =>
105102
event.timestamp < earliestEvent.timestamp ? event : earliestEvent,
106103
);
@@ -118,11 +115,7 @@ export default class Recorder {
118115
);
119116
}
120117

121-
this._addEndEvent(recordingSpan, replayId);
122-
123118
recordingSpan.end();
124-
125-
return tracing.exporter.toPayload();
126119
}
127120

128121
start() {
@@ -176,6 +169,20 @@ export default class Recorder {
176169
};
177170
}
178171

172+
_collectEvents() {
173+
const events = this._events.previous.concat(this._events.current);
174+
175+
// Helps the application correctly align playback by adding a noop event
176+
// to the end of the recording.
177+
events.push({
178+
timestamp: Date.now(),
179+
type: EventType.Custom,
180+
data: { tag: 'replay.end', payload: {} },
181+
});
182+
183+
return events;
184+
}
185+
179186
_logEvent(event, isCheckout) {
180187
logger.log(
181188
`Recorder: ${isCheckout ? 'checkout' : ''} event\n`,
@@ -195,19 +202,4 @@ export default class Recorder {
195202
})(event),
196203
);
197204
}
198-
199-
/**
200-
* Helps the application correctly align playback by adding a noop event
201-
* to the end of the recording.
202-
**/
203-
_addEndEvent(recordingSpan, replayId) {
204-
recordingSpan.addEvent(
205-
'rrweb-replay-events',
206-
{
207-
eventType: 5,
208-
json: JSON.stringify({tag: "replay.end", payload: {}}),
209-
},
210-
hrtime.fromMillis(Date.now()),
211-
);
212-
}
213205
}

src/browser/replay/replayManager.js

Lines changed: 23 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -42,33 +42,31 @@ export default class ReplayManager {
4242
}
4343

4444
/**
45-
* Processes a replay by converting recorder events into a transport-ready payload.
45+
* Exports recording and telemetry spans, then stores the tracing payload in the map.
4646
*
47-
* Calls recorder.dump() to capture events as spans, formats them into a proper payload,
48-
* and stores the result in the map using replayId as the key.
47+
* Exports both telemetry and recording spans, then generates the complete payload
48+
* using the tracing exporter and stores it in the map using replayId as the key.
49+
* This is an async operation that runs in the background.
4950
*
5051
* @param {string} replayId - The unique ID for this replay
51-
* @returns {Promise<string>} A promise resolving to the processed replayId
52+
* @param {string} occurrenceUuid - The UUID of the associated error occurrence
5253
* @private
5354
*/
54-
async _processReplay(replayId, occurrenceUuid) {
55+
async _exportSpansAndAddTracingPayload(replayId, occurrenceUuid) {
5556
try {
56-
this._telemeter?.exportTelemetrySpan({ 'rollbar.replay.id': replayId });
57-
58-
const payload = this._recorder.dump(
59-
this._tracing,
60-
replayId,
61-
occurrenceUuid,
62-
);
63-
64-
this._map.set(replayId, payload);
65-
} catch (transformError) {
66-
logger.error('Error transforming spans:', transformError);
67-
68-
this._map.set(replayId, null); // TODO(matux): Error span?
57+
this._recorder.exportRecordingSpan(this._tracing, {
58+
'rollbar.replay.id': replayId,
59+
'rollbar.occurrence.uuid': occurrenceUuid,
60+
});
61+
} catch (error) {
62+
logger.error('Error exporting recording span:', error);
63+
return;
6964
}
7065

71-
return replayId;
66+
this._telemeter?.exportTelemetrySpan({ 'rollbar.replay.id': replayId });
67+
68+
const payload = this._tracing.exporter.toPayload();
69+
this._map.set(replayId, payload);
7270
}
7371

7472
/**
@@ -83,9 +81,8 @@ export default class ReplayManager {
8381
add(replayId, occurrenceUuid) {
8482
replayId = replayId || id.gen(8);
8583

86-
this._processReplay(replayId, occurrenceUuid).catch((error) => {
87-
logger.error('Failed to process replay:', error);
88-
});
84+
// Start processing the replay in the background
85+
this._exportSpansAndAddTracingPayload(replayId, occurrenceUuid);
8986

9087
return replayId;
9188
}
@@ -103,15 +100,11 @@ export default class ReplayManager {
103100
*/
104101
async send(replayId) {
105102
if (!replayId) {
106-
logger.error('ReplayManager.send: No replayId provided');
107-
return false;
103+
throw Error('ReplayManager.send: No replayId provided');
108104
}
109105

110106
if (!this._map.has(replayId)) {
111-
logger.error(
112-
`ReplayManager.send: No replay found for replayId: ${replayId}`,
113-
);
114-
return false;
107+
throw Error(`ReplayManager.send: No replay found for id: ${replayId}`);
115108
}
116109

117110
const payload = this._map.get(replayId);
@@ -124,19 +117,10 @@ export default class ReplayManager {
124117
(payload.resourceSpans && payload.resourceSpans.length === 0);
125118

126119
if (isEmpty) {
127-
logger.error(
128-
`ReplayManager.send: No payload found for replayId: ${replayId}`,
129-
);
130-
return false;
120+
throw Error(`ReplayManager.send: No payload found for id: ${replayId}`);
131121
}
132122

133-
try {
134-
await this._api.postSpans(payload, { 'X-Rollbar-Replay-Id': replayId });
135-
return true;
136-
} catch (error) {
137-
logger.error('Error sending replay:', error);
138-
return false;
139-
}
123+
await this._api.postSpans(payload, { 'X-Rollbar-Replay-Id': replayId });
140124
}
141125

142126
/**

src/queue.js

Lines changed: 52 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,10 @@ class Queue {
102102
}
103103

104104
if (this.replayManager && data.body) {
105-
const replayId = data?.attributes?.find(
106-
(a) => a.key === 'replay_id',
107-
)?.value;
105+
const rid = data.attributes?.find((a) => a.key === 'replay_id')?.value;
108106

109-
if (replayId) {
110-
item.replayId = this.replayManager.add(replayId, data.uuid);
107+
if (rid) {
108+
item.replayId = this.replayManager.add(rid, data.uuid);
111109
}
112110
}
113111

@@ -116,15 +114,20 @@ class Queue {
116114
this._makeApiRequest(data, (err, resp, headers) => {
117115
this._dequeuePendingRequest(data);
118116

119-
if (!err && resp && item.replayId) {
120-
this._handleReplayResponse(item.replayId, resp, headers);
117+
if (item.replayId) {
118+
this._sendOrDiscardReplay(item.replayId, err, resp, headers);
121119
}
122120

123121
callback(err, resp);
124122
});
125-
} catch (e) {
123+
} catch (err) {
126124
this._dequeuePendingRequest(data);
127-
callback(e);
125+
126+
if (item.replayId) {
127+
this.replayManager?.discard(item.replayId);
128+
}
129+
130+
callback(err);
128131
}
129132
}
130133

@@ -305,51 +308,54 @@ class Queue {
305308
}
306309

307310
/**
308-
* Handles the API response for an item with a replay ID.
309-
* Based on the success or failure status of the response,
310-
* it either sends or discards the associated session replay.
311+
* Determines if a replay can be sent based on the API response.
312+
*
313+
* Returns true only when all conditions are met:
314+
* - No error occurred during the API request
315+
* - The response indicates success (err === 0)
316+
* - Replay is enabled on the server
317+
* - Rate limit quota is not exhausted
318+
*
319+
* @param {Error|null} err - Error from the API request, if any
320+
* @param {Object} response - The API response object
321+
* @param {Object} headers - Response headers from the API
322+
* @returns {boolean} True if the replay can be sent, false otherwise
323+
*/
324+
_canSendReplay(err, response, headers) {
325+
return (
326+
!err &&
327+
response?.err === 0 &&
328+
headers?.['Rollbar-Replay-Enabled'] === 'true' &&
329+
headers?.['Rollbar-Replay-RateLimit-Remaining'] !== '0'
330+
);
331+
}
332+
333+
/**
334+
* Sends or discards a replay based on the API response.
335+
*
336+
* Sends the replay if the response indicates success and replay is enabled.
337+
*
338+
* Discards the replay if there's an error, replay is disabled, or rate limit
339+
* is exceeded.
311340
*
312-
* @param {string} replayId - The ID of the replay to handle
341+
* @param {string} replayId - The ID of the replay to send or discard
342+
* @param {Object} err - The error object from the API request, if any
313343
* @param {Object} response - The API response
314344
* @param {Object} headers - The response headers
315-
* @returns {Promise<boolean>} A promise that resolves to true if replay was sent successfully,
316-
* false if replay was discarded or an error occurred
317345
*/
318-
async _handleReplayResponse(replayId, response, headers) {
319-
if (!this.replayManager) {
320-
console.warn('Queue._handleReplayResponse: ReplayManager not available');
321-
return false;
322-
}
323-
324-
if (!replayId) {
325-
console.warn('Queue._handleReplayResponse: No replayId provided');
326-
return false;
327-
}
346+
async _sendOrDiscardReplay(replayId, err, response, headers) {
347+
const canSendReplay = this._canSendReplay(err, response, headers);
328348

329-
try {
330-
if (this._shouldSendReplay(response, headers)) {
331-
return await this.replayManager.send(replayId);
332-
} else {
349+
if (canSendReplay) {
350+
try {
351+
await this.replayManager.send(replayId);
352+
} catch (error) {
353+
console.error('Failed to send replay:', error);
333354
this.replayManager.discard(replayId);
334-
return false;
335355
}
336-
} catch (error) {
337-
console.error('Error handling replay response:', error);
338-
return false;
339-
}
340-
}
341-
342-
_shouldSendReplay(response, headers) {
343-
if (
344-
response?.err !== 0 ||
345-
!headers ||
346-
headers['Rollbar-Replay-Enabled'] !== 'true' ||
347-
headers['Rollbar-Replay-RateLimit-Remaining'] === '0'
348-
) {
349-
return false;
356+
} else {
357+
this.replayManager.discard(replayId);
350358
}
351-
352-
return true;
353359
}
354360
}
355361

0 commit comments

Comments
 (0)