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
33 changes: 33 additions & 0 deletions src/browser/replay/replayManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export default class ReplayManager {
);
return null;
}

replayId = replayId || id.gen(8);

// Start processing the replay in the background
Expand All @@ -243,6 +244,38 @@ export default class ReplayManager {
return replayId;
}

/**
* Sends or discards a replay based on whether it can be sent.
*
* The criteria for sending a replay are:
* - No error occurred during the API request
* - The response indicates success (err === 0)
* - Replay is enabled on the server
* - Rate limit quota is not exhausted
*
* Called by Queue after determining replay eligibility from API response.
*
* @param {string} replayId - The ID of the replay to send or discard
*/
async sendOrDiscardReplay(replayId, err, resp, headers) {
const canSendReplay =
!err &&
resp?.err === 0 &&
headers?.['Rollbar-Replay-Enabled'] === 'true' &&
headers?.['Rollbar-Replay-RateLimit-Remaining'] !== '0';

if (canSendReplay) {
try {
await this.send(replayId);
} catch (error) {
logger.error('Failed to send replay:', error);
this.discard(replayId);
}
} else {
this.discard(replayId);
}
}

/**
* Sends the replay payload associated with the given replayId to the backend
* and removes it from the map.
Expand Down
58 changes: 6 additions & 52 deletions src/queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,12 @@ class Queue {
this._dequeuePendingRequest(data);

if (item.replayId) {
this._sendOrDiscardReplay(item.replayId, err, resp, headers);
this.replayManager.sendOrDiscardReplay(
item.replayId,
err,
resp,
headers,
);
}

callback(err, resp);
Expand Down Expand Up @@ -306,57 +311,6 @@ class Queue {
}
return false;
}

/**
* Determines if a replay can be sent based on the API response.
*
* Returns true only when all conditions are met:
* - No error occurred during the API request
* - The response indicates success (err === 0)
* - Replay is enabled on the server
* - Rate limit quota is not exhausted
*
* @param {Error|null} err - Error from the API request, if any
* @param {Object} response - The API response object
* @param {Object} headers - Response headers from the API
* @returns {boolean} True if the replay can be sent, false otherwise
*/
_canSendReplay(err, response, headers) {
return (
!err &&
response?.err === 0 &&
headers?.['Rollbar-Replay-Enabled'] === 'true' &&
headers?.['Rollbar-Replay-RateLimit-Remaining'] !== '0'
);
}

/**
* Sends or discards a replay based on the API response.
*
* Sends the replay if the response indicates success and replay is enabled.
*
* Discards the replay if there's an error, replay is disabled, or rate limit
* is exceeded.
*
* @param {string} replayId - The ID of the replay to send or discard
* @param {Object} err - The error object from the API request, if any
* @param {Object} response - The API response
* @param {Object} headers - The response headers
*/
async _sendOrDiscardReplay(replayId, err, response, headers) {
const canSendReplay = this._canSendReplay(err, response, headers);

if (canSendReplay) {
try {
await this.replayManager.send(replayId);
} catch (error) {
console.error('Failed to send replay:', error);
this.replayManager.discard(replayId);
}
} else {
this.replayManager.discard(replayId);
}
}
}

export default Queue;
91 changes: 59 additions & 32 deletions test/replay/integration/queue.replayManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe('Queue ReplayManager Integration', function () {
capture: sinon.stub().returnsArg(0),
send: sinon.stub().resolves(true),
discard: sinon.stub().returns(true),
sendOrDiscardReplay: sinon.stub().resolves(),
getSpans: sinon.stub().returns([{ id: 'test-span' }]),
setSpans: sinon.stub(),
size: 0,
Expand Down Expand Up @@ -85,7 +86,7 @@ describe('Queue ReplayManager Integration', function () {
});
});

it('should call replayManager.send when API response is successful', function (done) {
it('should call sendOrDiscardReplay after successful API response', function (done) {
const item = {
data: {
body: {
Expand All @@ -101,13 +102,17 @@ describe('Queue ReplayManager Integration', function () {

queue.addItem(item, () => {
setTimeout(() => {
expect(replayManager.send.calledWith('1234567812345678')).to.be.true;
expect(replayManager.sendOrDiscardReplay.calledOnce).to.be.true;
expect(replayManager.sendOrDiscardReplay.firstCall.args[0]).to.equal('1234567812345678');
expect(replayManager.sendOrDiscardReplay.firstCall.args[1]).to.be.null; // no error
expect(replayManager.sendOrDiscardReplay.firstCall.args[2]).to.deep.equal({ err: 0, result: { id: '12345' } });
expect(replayManager.sendOrDiscardReplay.firstCall.args[3]).to.deep.equal({ 'Rollbar-Replay-Enabled': 'true' });
done();
}, 50);
});
});

it('should call replayManager.discard when API response has error', function (done) {
it('should call sendOrDiscardReplay when API response has error', function (done) {
transport.post.callsFake(({ accessToken, options, payload, callback }) => {
setTimeout(() => {
callback(null, { err: 1, message: 'API Error' });
Expand All @@ -129,14 +134,16 @@ describe('Queue ReplayManager Integration', function () {

queue.addItem(item, () => {
setTimeout(() => {
expect(replayManager.discard.calledWith('1234567812345678')).to.be.true;
expect(replayManager.send.called).to.be.false;
expect(replayManager.sendOrDiscardReplay.calledOnce).to.be.true;
expect(replayManager.sendOrDiscardReplay.firstCall.args[0]).to.equal('1234567812345678');
expect(replayManager.sendOrDiscardReplay.firstCall.args[1]).to.be.null;
expect(replayManager.sendOrDiscardReplay.firstCall.args[2]).to.deep.equal({ err: 1, message: 'API Error' });
done();
}, 50);
});
});

it('should call replayManager.discard when replay is disabled', function (done) {
it('should call sendOrDiscardReplay when replay is disabled in headers', function (done) {
transport.post.callsFake(({ accessToken, options, payload, callback }) => {
setTimeout(() => {
callback(
Expand All @@ -162,14 +169,17 @@ describe('Queue ReplayManager Integration', function () {

queue.addItem(item, () => {
setTimeout(() => {
expect(replayManager.discard.calledWith('1234567812345678')).to.be.true;
expect(replayManager.send.called).to.be.false;
expect(replayManager.sendOrDiscardReplay.calledOnce).to.be.true;
expect(replayManager.sendOrDiscardReplay.firstCall.args[0]).to.equal('1234567812345678');
expect(replayManager.sendOrDiscardReplay.firstCall.args[1]).to.be.null;
expect(replayManager.sendOrDiscardReplay.firstCall.args[2]).to.have.property('err', 0);
expect(replayManager.sendOrDiscardReplay.firstCall.args[3]).to.deep.equal({ 'Rollbar-Replay-Enabled': 'false' });
done();
}, 50);
});
});

it('should call replayManager.discard when over quota', function (done) {
it('should call sendOrDiscardReplay when rate limit is exhausted', function (done) {
transport.post.callsFake(({ accessToken, options, payload, callback }) => {
setTimeout(() => {
callback(
Expand Down Expand Up @@ -198,8 +208,14 @@ describe('Queue ReplayManager Integration', function () {

queue.addItem(item, () => {
setTimeout(() => {
expect(replayManager.discard.calledWith('1234567812345678')).to.be.true;
expect(replayManager.send.called).to.be.false;
expect(replayManager.sendOrDiscardReplay.calledOnce).to.be.true;
expect(replayManager.sendOrDiscardReplay.firstCall.args[0]).to.equal('1234567812345678');
expect(replayManager.sendOrDiscardReplay.firstCall.args[1]).to.be.null;
expect(replayManager.sendOrDiscardReplay.firstCall.args[2]).to.have.property('err', 0);
expect(replayManager.sendOrDiscardReplay.firstCall.args[3]).to.deep.equal({
'Rollbar-Replay-Enabled': 'true',
'Rollbar-Replay-RateLimit-Remaining': '0',
});
done();
}, 50);
});
Expand Down Expand Up @@ -244,7 +260,11 @@ describe('Queue ReplayManager Integration', function () {
expect(item).to.have.property('replayId', '1234567812345678');

setTimeout(() => {
expect(replayManager.send.calledWith('1234567812345678')).to.be.true;
// After retry succeeds, sendOrDiscardReplay should be called
expect(replayManager.sendOrDiscardReplay.called).to.be.true;
expect(replayManager.sendOrDiscardReplay.firstCall.args[0]).to.equal('1234567812345678');
expect(replayManager.sendOrDiscardReplay.firstCall.args[1]).to.be.null; // no error
expect(replayManager.sendOrDiscardReplay.firstCall.args[2]).to.deep.equal({ err: 0, result: { id: '12345' } });
done();
}, 50);
}
Expand Down Expand Up @@ -290,11 +310,12 @@ describe('Queue ReplayManager Integration', function () {
});

describe('Memory leak prevention - replay cleanup', function () {
it('should always discard replay on transport error', function (done) {
it('should call sendOrDiscardReplay on transport error', function (done) {
const transportError = new Error('Transport failed');
transport.post.callsFake(
({ accessToken, options, payload, callback }) => {
setTimeout(() => {
callback(new Error('Transport failed'));
callback(transportError);
}, 10);
},
);
Expand All @@ -316,19 +337,19 @@ describe('Queue ReplayManager Integration', function () {
queue.addItem(item, (err) => {
expect(err).to.be.instanceof(Error);
setTimeout(() => {
// Verify replay was added but then discarded
// Verify replay was added and sendOrDiscardReplay was called
expect(replayManager.capture.calledOnce).to.be.true;
expect(replayManager.discard.calledWith('1234567812345678')).to.be
.true;
expect(replayManager.send.called).to.be.false;
expect(replayManager.sendOrDiscardReplay.calledOnce).to.be.true;
expect(replayManager.sendOrDiscardReplay.firstCall.args[0]).to.equal('1234567812345678');
expect(replayManager.sendOrDiscardReplay.firstCall.args[1]).to.equal(transportError);
// Ensure no memory leak - replay should be removed from map
expect(replayManager.size).to.equal(0);
done();
}, 50);
});
});

it('should discard replay when headers are missing', function (done) {
it('should call sendOrDiscardReplay when headers are missing', function (done) {
transport.post.callsFake(
({ accessToken, options, payload, callback }) => {
setTimeout(() => {
Expand All @@ -353,15 +374,17 @@ describe('Queue ReplayManager Integration', function () {

queue.addItem(item, () => {
setTimeout(() => {
expect(replayManager.discard.calledWith('1234567812345678')).to.be
.true;
expect(replayManager.send.called).to.be.false;
expect(replayManager.sendOrDiscardReplay.calledOnce).to.be.true;
expect(replayManager.sendOrDiscardReplay.firstCall.args[0]).to.equal('1234567812345678');
expect(replayManager.sendOrDiscardReplay.firstCall.args[1]).to.be.null;
expect(replayManager.sendOrDiscardReplay.firstCall.args[2]).to.deep.equal({ err: 0, result: { id: '12345' } });
expect(replayManager.sendOrDiscardReplay.firstCall.args[3]).to.be.null;
done();
}, 50);
});
});

it('should discard replay on rate limit header zero', function (done) {
it('should call sendOrDiscardReplay on rate limit header zero', function (done) {
transport.post.callsFake(
({ accessToken, options, payload, callback }) => {
setTimeout(() => {
Expand Down Expand Up @@ -392,9 +415,12 @@ describe('Queue ReplayManager Integration', function () {

queue.addItem(item, () => {
setTimeout(() => {
expect(replayManager.discard.calledWith('1234567812345678')).to.be
.true;
expect(replayManager.send.called).to.be.false;
expect(replayManager.sendOrDiscardReplay.calledOnce).to.be.true;
expect(replayManager.sendOrDiscardReplay.firstCall.args[0]).to.equal('1234567812345678');
expect(replayManager.sendOrDiscardReplay.firstCall.args[3]).to.deep.equal({
'Rollbar-Replay-Enabled': 'true',
'Rollbar-Replay-RateLimit-Remaining': '0',
});
done();
}, 50);
});
Expand Down Expand Up @@ -431,9 +457,8 @@ describe('Queue ReplayManager Integration', function () {
completed++;
if (completed === items.length) {
setTimeout(() => {
// All replays should be discarded
expect(replayManager.discard.callCount).to.equal(5);
expect(replayManager.send.called).to.be.false;
// All replays should have sendOrDiscardReplay called
expect(replayManager.sendOrDiscardReplay.callCount).to.equal(5);
// No memory leaks
expect(replayManager.size).to.equal(0);
done();
Expand All @@ -444,7 +469,7 @@ describe('Queue ReplayManager Integration', function () {
});
});

it('should discard replay on null response', function (done) {
it('should call sendOrDiscardReplay on null response', function (done) {
transport.post.callsFake(({ accessToken, options, payload, callback }) => {
setTimeout(() => {
callback(null, null);
Expand All @@ -466,8 +491,10 @@ describe('Queue ReplayManager Integration', function () {

queue.addItem(item, () => {
setTimeout(() => {
expect(replayManager.send.called).to.be.false;
expect(replayManager.discard.calledWith('1234567812345678')).to.be.true;
expect(replayManager.sendOrDiscardReplay.calledOnce).to.be.true;
expect(replayManager.sendOrDiscardReplay.firstCall.args[0]).to.equal('1234567812345678');
expect(replayManager.sendOrDiscardReplay.firstCall.args[1]).to.be.null;
expect(replayManager.sendOrDiscardReplay.firstCall.args[2]).to.be.null;
done();
}, 50);
});
Expand Down
Loading
Loading