Skip to content

Commit 23fc2e0

Browse files
committed
add ReplayManager.triggerReplay
1 parent a92b382 commit 23fc2e0

File tree

4 files changed

+130
-0
lines changed

4 files changed

+130
-0
lines changed

src/browser/core.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,16 @@ class Rollbar {
194194
return this.client.sendJsonPayload(jsonPayload);
195195
}
196196

197+
triggerDirectReplay(context) {
198+
return this.triggerReplay({ type: 'direct', ...context });
199+
}
200+
201+
triggerReplay(context) {
202+
if (!this.replayManager) return null;
203+
204+
return this.replayManager.triggerReplay(context);
205+
}
206+
197207
setupUnhandledCapture() {
198208
var gWindow = _gWindow();
199209

src/browser/replay/replayManager.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const TrailingStatus = Object.freeze({
1616
PENDING: 'pending', // Trailing not yet sent
1717
SENT: 'sent', // Trailing successfully sent
1818
FAILED: 'failed', // Trailing failed to send
19+
SKIPPED: 'skipped', // Trailing was skipped (replay is leading only)
1920
});
2021

2122
/**
@@ -184,6 +185,52 @@ export default class ReplayManager {
184185
return replayId;
185186
}
186187

188+
/**
189+
* On a matching trigger condition, captures and sends a replay.
190+
* This method handles the non-occurrence based triggers, which don't require
191+
* special occurrence-specific handling.
192+
*
193+
* @returns {string} A unique identifier for this replay or null if not sent.
194+
*/
195+
async triggerReplay(triggerContext) {
196+
const replayId = id.gen(8);
197+
198+
const trigger = this._predicates.shouldCaptureForTriggerContext({
199+
...triggerContext,
200+
replayId,
201+
});
202+
if (!trigger) {
203+
return null;
204+
}
205+
206+
if (this._recorder.isReady) {
207+
await this._exportSpansAndAddTracingPayload(
208+
replayId,
209+
null,
210+
trigger,
211+
triggerContext,
212+
);
213+
} else {
214+
// If the recorder is not ready, mark the trailing capture as skipped and
215+
// allow the leading capture to proceed.
216+
this._trailingStatus.set(replayId, TrailingStatus.SKIPPED);
217+
218+
const leadingSeconds = this._recorder.options?.postDuration || 0;
219+
if (leadingSeconds > 0) {
220+
this._scheduleLeadingCapture(replayId, null, leadingSeconds);
221+
}
222+
}
223+
224+
try {
225+
await this.send(replayId);
226+
} catch (error) {
227+
this.discard(replayId);
228+
return null;
229+
}
230+
231+
return replayId;
232+
}
233+
187234
/**
188235
* Determines if a replay can be sent based on API response and headers.
189236
*

src/browser/telemetry.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,9 @@ class Instrumenter {
670670
this.addListener('dom', this._window, ['resize'], (e) =>
671671
this.handleEvent('resize', e),
672672
);
673+
this.addListener('dom', this._document, ['DOMContentLoaded'], (e) =>
674+
this.handleEvent('contentLoaded', e),
675+
);
673676
}
674677

675678
handleEvent(name, evt) {
@@ -681,12 +684,20 @@ class Instrumenter {
681684
form: this.handleForm,
682685
input: this.handleInput,
683686
resize: this.handleResize,
687+
contentLoaded: this.handleContentLoaded,
684688
}[name].call(this, evt);
685689
} catch (exc) {
686690
console.log(`${name} handler error`, evt, exc, exc.stack);
687691
}
688692
}
689693

694+
handleContentLoaded(evt) {
695+
const replayId = this.rollbar.triggerReplay({
696+
type: 'navigation',
697+
path: new URL(this._location.href).pathname,
698+
});
699+
}
700+
690701
handleClick(evt) {
691702
const tagName = evt.target?.tagName.toLowerCase();
692703
if (['input', 'select', 'textarea'].includes(tagName)) return;
@@ -882,6 +893,10 @@ class Instrumenter {
882893
from = parsedFrom.path + (parsedFrom.hash || '');
883894
}
884895
this.telemeter.captureNavigation(from, to, null, _.now());
896+
const replayId = this.rollbar.triggerReplay({
897+
type: 'navigation',
898+
path: to,
899+
});
885900
}
886901

887902
deinstrumentConnectivity = function () {

test/replay/integration/e2e.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ const options = {
3232
{
3333
type: 'occurrence',
3434
},
35+
{
36+
type: 'direct',
37+
tags: ['e2e-test'],
38+
},
3539
],
3640
recordFn: mockRecordFn,
3741
},
@@ -310,6 +314,60 @@ describe('Session Replay E2E', function () {
310314
}, 50);
311315
});
312316

317+
it('should handle non-occurrence triggerReplay', function (done) {
318+
this.timeout(1000);
319+
320+
recorder.configure({ ...recorder.options, postDuration: 0.1 });
321+
recorder.start();
322+
323+
setTimeout(() => {
324+
const recorderExportSpy = sinon.spy(recorder, 'exportRecordingSpan');
325+
const replayManagerSendSpy = sinon.spy(replayManager, 'send');
326+
const apiPostSpansSpy = sinon.spy(tracing.exporter, 'post');
327+
328+
replayManager.triggerReplay({
329+
type: 'direct',
330+
tags: ['e2e-test'],
331+
});
332+
333+
setTimeout(() => {
334+
expect(recorderExportSpy.called).to.be.true;
335+
const expectedReplayId =
336+
recorderExportSpy.firstCall.args[1]['rollbar.replay.id'];
337+
expect(replayManagerSendSpy.calledWith(expectedReplayId)).to.be.true;
338+
339+
const trailingExportCall = recorderExportSpy.firstCall;
340+
expect(trailingExportCall.args[2]).to.be.undefined;
341+
342+
setTimeout(() => {
343+
expect(apiPostSpansSpy.callCount).to.equal(2);
344+
345+
const trailingApiCall = apiPostSpansSpy.firstCall;
346+
const leadingApiCall = apiPostSpansSpy.secondCall;
347+
348+
expect(trailingApiCall.args[1]).to.deep.equal({
349+
'X-Rollbar-Replay-Id': expectedReplayId,
350+
});
351+
expect(leadingApiCall.args[1]).to.deep.equal({
352+
'X-Rollbar-Replay-Id': expectedReplayId,
353+
});
354+
355+
expect(recorderExportSpy.callCount).to.be.greaterThan(1);
356+
const leadingExportCall = recorderExportSpy.lastCall;
357+
const leadingCursor = leadingExportCall.args[2];
358+
359+
expect(leadingCursor).to.be.an('object');
360+
expect(leadingCursor).to.have.property('slot');
361+
expect(leadingCursor).to.have.property('offset');
362+
expect(leadingCursor.slot).to.be.oneOf([0, 1]);
363+
expect(leadingCursor.offset).to.be.a('number');
364+
365+
done();
366+
}, 200);
367+
}, 200);
368+
}, 50);
369+
});
370+
313371
it('should integrate with real components in failure scenario', function (done) {
314372
transport.post.callsFake(({ accessToken, options, payload, callback }) => {
315373
if (options.path.includes('/api/1/item/')) {

0 commit comments

Comments
 (0)