diff --git a/src/browser/core.js b/src/browser/core.js index 3f84ade5..846c5b8e 100644 --- a/src/browser/core.js +++ b/src/browser/core.js @@ -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(); diff --git a/src/browser/replay/replayManager.js b/src/browser/replay/replayManager.js index 57a50900..a549d2a0 100644 --- a/src/browser/replay/replayManager.js +++ b/src/browser/replay/replayManager.js @@ -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) }); /** @@ -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. * diff --git a/src/browser/telemetry.js b/src/browser/telemetry.js index 41f22150..ebc7e23c 100644 --- a/src/browser/telemetry.js +++ b/src/browser/telemetry.js @@ -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) { @@ -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; @@ -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 () { diff --git a/test/replay/integration/e2e.test.js b/test/replay/integration/e2e.test.js index b599b2fb..02bd701e 100644 --- a/test/replay/integration/e2e.test.js +++ b/test/replay/integration/e2e.test.js @@ -32,6 +32,10 @@ const options = { { type: 'occurrence', }, + { + type: 'direct', + tags: ['e2e-test'], + }, ], recordFn: mockRecordFn, }, @@ -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/')) {