Skip to content

Commit b54eab8

Browse files
committed
add ReplayManager.triggerReplay
1 parent 1e47372 commit b54eab8

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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const TrailingStatus = Object.freeze({
1515
PENDING: 'pending', // Trailing not yet sent
1616
SENT: 'sent', // Trailing successfully sent
1717
FAILED: 'failed', // Trailing failed to send
18+
SKIPPED: 'skipped', // Trailing was skipped (replay is leading only)
1819
});
1920

2021
/**
@@ -192,6 +193,7 @@ export default class ReplayManager {
192193

193194
switch (trailingStatus) {
194195
case TrailingStatus.SENT:
196+
case TrailingStatus.SKIPPED:
195197
try {
196198
await this._tracing.exporter.post(pendingContext.leadingPayload, {
197199
'X-Rollbar-Replay-Id': replayId,
@@ -265,6 +267,52 @@ export default class ReplayManager {
265267
return replayId;
266268
}
267269

270+
/**
271+
* On a matching trigger condition, captures and sends a replay.
272+
* This method handles the non-occurrence based triggers, which don't require
273+
* special occurrence-specific handling.
274+
*
275+
* @returns {string} A unique identifier for this replay or null if not sent.
276+
*/
277+
async triggerReplay(triggerContext) {
278+
const replayId = id.gen(8);
279+
280+
const trigger = this._predicates.shouldCaptureForTriggerContext({
281+
...triggerContext,
282+
replayId,
283+
});
284+
if (!trigger) {
285+
return null;
286+
}
287+
288+
if (this._recorder.isReady) {
289+
await this._exportSpansAndAddTracingPayload(
290+
replayId,
291+
null,
292+
trigger,
293+
triggerContext,
294+
);
295+
} else {
296+
// If the recorder is not ready, mark the trailing capture as skipped and
297+
// allow the leading capture to proceed.
298+
this._trailingStatus.set(replayId, TrailingStatus.SKIPPED);
299+
300+
const leadingSeconds = this._recorder.options?.postDuration || 0;
301+
if (leadingSeconds > 0) {
302+
this._scheduleLeadingCapture(replayId, null, leadingSeconds);
303+
}
304+
}
305+
306+
try {
307+
await this.send(replayId);
308+
} catch (error) {
309+
this.discard(replayId);
310+
return null;
311+
}
312+
313+
return replayId;
314+
}
315+
268316
/**
269317
* Determines if a replay can be sent based on API response and headers.
270318
*

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: 57 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
},
@@ -304,6 +308,59 @@ describe('Session Replay E2E', function () {
304308
}, 50);
305309
});
306310

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

0 commit comments

Comments
 (0)