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
10 changes: 10 additions & 0 deletions src/browser/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,16 @@ class Rollbar {
return this.client.sendJsonPayload(jsonPayload);
}

triggerDirectReplay(context) {
return this.triggerReplay({ type: 'direct', ...context });
}

triggerReplay(context) {
if (!this.replayManager) return null;

return this.replayManager.triggerReplay(context);
}

setupUnhandledCapture() {
var gWindow = _gWindow();

Expand Down
47 changes: 47 additions & 0 deletions src/browser/replay/replayManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const TrailingStatus = Object.freeze({
PENDING: 'pending', // Trailing not yet sent
SENT: 'sent', // Trailing successfully sent
FAILED: 'failed', // Trailing failed to send
SKIPPED: 'skipped', // Trailing was skipped (replay is leading only)
});

/**
Expand Down Expand Up @@ -184,6 +185,52 @@ export default class ReplayManager {
return replayId;
}

/**
* On a matching trigger condition, captures and sends a replay.
* This method handles the non-occurrence based triggers, which don't require
* special occurrence-specific handling.
*
* @returns {string} A unique identifier for this replay or null if not sent.
*/
async triggerReplay(triggerContext) {
const replayId = id.gen(8);

const trigger = this._predicates.shouldCaptureForTriggerContext({
...triggerContext,
replayId,
});
if (!trigger) {
return null;
}

if (this._recorder.isReady) {
await this._exportSpansAndAddTracingPayload(
replayId,
null,
trigger,
triggerContext,
);
} else {
// If the recorder is not ready, mark the trailing capture as skipped and
// allow the leading capture to proceed.
this._trailingStatus.set(replayId, TrailingStatus.SKIPPED);

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

try {
await this.send(replayId);
} catch (error) {
this.discard(replayId);
return null;
}

return replayId;
}

/**
* Determines if a replay can be sent based on API response and headers.
*
Expand Down
15 changes: 15 additions & 0 deletions src/browser/telemetry.js
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,9 @@ class Instrumenter {
this.addListener('dom', this._window, ['resize'], (e) =>
this.handleEvent('resize', e),
);
this.addListener('dom', this._document, ['DOMContentLoaded'], (e) =>
this.handleEvent('contentLoaded', e),
);
}

handleEvent(name, evt) {
Expand All @@ -681,12 +684,20 @@ class Instrumenter {
form: this.handleForm,
input: this.handleInput,
resize: this.handleResize,
contentLoaded: this.handleContentLoaded,
}[name].call(this, evt);
} catch (exc) {
console.log(`${name} handler error`, evt, exc, exc.stack);
}
}

handleContentLoaded(evt) {
const replayId = this.rollbar.triggerReplay({
type: 'navigation',
path: new URL(this._location.href).pathname,
});
}

handleClick(evt) {
const tagName = evt.target?.tagName.toLowerCase();
if (['input', 'select', 'textarea'].includes(tagName)) return;
Expand Down Expand Up @@ -882,6 +893,10 @@ class Instrumenter {
from = parsedFrom.path + (parsedFrom.hash || '');
}
this.telemeter.captureNavigation(from, to, null, _.now());
const replayId = this.rollbar.triggerReplay({
type: 'navigation',
path: to,
});
}

deinstrumentConnectivity = function () {
Expand Down
64 changes: 64 additions & 0 deletions test/replay/integration/e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const options = {
{
type: 'occurrence',
},
{
type: 'direct',
tags: ['e2e-test'],
},
],
recordFn: mockRecordFn,
},
Expand Down Expand Up @@ -310,6 +314,66 @@ describe('Session Replay E2E', function () {
}, 50);
});

it('should handle non-occurrence triggerReplay', function (done) {
this.timeout(1000);

replayManager.configure({
...options.replay,
triggerDefaults: {
preDuration: 300,
postDuration: 0.1,
},
});
recorder.start();

setTimeout(() => {
const recorderExportSpy = sinon.spy(recorder, 'exportRecordingSpan');
const replayManagerSendSpy = sinon.spy(replayManager, 'send');
const apiPostSpansSpy = sinon.spy(tracing.exporter, 'post');

replayManager.triggerReplay({
type: 'direct',
tags: ['e2e-test'],
});

setTimeout(() => {
expect(recorderExportSpy.called).to.be.true;
const expectedReplayId =
recorderExportSpy.firstCall.args[1]['rollbar.replay.id'];
expect(replayManagerSendSpy.calledWith(expectedReplayId)).to.be.true;

const trailingExportCall = recorderExportSpy.firstCall;
expect(trailingExportCall.args[2]).to.be.undefined;

setTimeout(() => {
expect(apiPostSpansSpy.callCount).to.equal(2);

const trailingApiCall = apiPostSpansSpy.firstCall;
const leadingApiCall = apiPostSpansSpy.secondCall;

expect(trailingApiCall.args[1]).to.deep.equal({
'X-Rollbar-Replay-Id': expectedReplayId,
});
expect(leadingApiCall.args[1]).to.deep.equal({
'X-Rollbar-Replay-Id': expectedReplayId,
});

expect(recorderExportSpy.callCount).to.be.greaterThan(1);
const leadingExportCall = recorderExportSpy.lastCall;
const leadingCursor = leadingExportCall.args[2];

expect(leadingCursor).to.be.an('object');
expect(leadingCursor).to.have.property('slot');
expect(leadingCursor).to.have.property('offset');
expect(leadingCursor.slot).to.be.oneOf([0, 1]);
expect(leadingCursor.offset).to.be.a('number');

done();
}, 200);
}, 200);
}, 50);
});

it('should integrate with real components in failure scenario', function (done) {
transport.post.callsFake(({ accessToken, options, payload, callback }) => {
if (options.path.includes('/api/1/item/')) {
Expand Down