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
2 changes: 2 additions & 0 deletions src/Gleap.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ class Gleap {
// otherwise only kicked off once the config round-trip finishes, which
// leaves a window (slow network, uncached first visit) where a user can
// open feedback before the recorder is running and submit an empty replay.
// It starts at the page's first idle moment (at most 2 s from now), so the
// full snapshot of a large page does not block its load.
// If this project has web replays disabled, applyConfig() stops the
// recorder again. That transient buffer can't reach a ticket: the feedback
// widget (the only thing that emits 'flow-started', which is what snapshots
Expand Down
50 changes: 50 additions & 0 deletions src/GleapIdle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Runs `callback` once the browser has an idle moment, at the latest after `timeout` ms, so work
* the page does not need right away stays out of its load and first interactions.
* Without requestIdleCallback (Safari) it runs right after the page's load event, again at the
* latest after `timeout` ms.
* @param {function} callback
* @param {number} timeout
* @returns {function} cancels the callback if it has not run yet
*/
export const runWhenIdle = (callback, timeout) => {
if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(callback, { timeout });
return () => {
if (typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(handle);
}
};
}

let finished = false;
let timer = null;
const canListen = typeof window !== 'undefined' && typeof window.addEventListener === 'function';
const cleanUp = () => {
finished = true;
clearTimeout(timer);
if (canListen) {
window.removeEventListener('load', onLoad);
}
};
const run = () => {
if (finished) {
return;
}
cleanUp();
callback();
};
const onLoad = () => {
clearTimeout(timer);
timer = setTimeout(run, 0);
};

const loaded = typeof document === 'undefined' || document.readyState === 'complete';
if (loaded || !canListen) {
timer = setTimeout(run, 0);
} else {
window.addEventListener('load', onLoad);
timer = setTimeout(run, timeout);
}
return cleanUp;
};
38 changes: 34 additions & 4 deletions src/GleapReplayRecorder.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isMobile } from './GleapHelper';
import { runWhenIdle } from './GleapIdle';
import { getReplayMaskingOptions } from './GleapInputMasking';
import { record } from '@rrweb/record';
import { pack } from '@rrweb/packer';
Expand Down Expand Up @@ -30,6 +31,11 @@ const MIN_FORCED_CHECKOUT_INTERVAL_MS = 30 * 1000;

const RRWEB_FULL_SNAPSHOT_EVENT_TYPE = 2;

// rrweb's first full snapshot serializes the whole page (and inlines its stylesheets) in one task:
// on a content-heavy page that is most of the SDK's main-thread work at load. The automatic
// starts wait for an idle moment, at most this long.
const IDLE_START_TIMEOUT_MS = 2000;

// Allocation-free stand-in for JSON.stringify(value).length. It runs on every
// recorded event, so it must stay cheap next to rrweb's own serialization.
export const approximateSize = (value) => {
Expand Down Expand Up @@ -68,6 +74,8 @@ export default class GleapReplayRecorder {
customOptions = {};
// A capture request's page recording runs rrweb itself, and rrweb records one session per page.
pausedForCapture = false;
// Cancels the start startIfNotRunning scheduled for the next idle moment.
cancelIdleStart = undefined;

// GleapReplayRecorder singleton
static instance;
Expand Down Expand Up @@ -100,6 +108,7 @@ export default class GleapReplayRecorder {
* @returns
*/
start(keepBuffer = false) {
this.clearIdleStart();
if (keepBuffer && this.startDate) {
if (this.stopFunction) {
try {
Expand Down Expand Up @@ -203,27 +212,42 @@ export default class GleapReplayRecorder {
}

/**
* Starts recording only if it isn't already running.
* Starts recording at the next idle moment (at the latest IDLE_START_TIMEOUT_MS from now), only
* if it isn't already running or about to start.
*
* The recorder is started eagerly at SDK init and then again once the config
* confirms web replays are enabled. Without this guard that second call (and
* the cached-vs-server config double apply) would call start(), which tears
* down and rebuilds the rolling buffer, throwing away everything captured so
* far.
* far. A stop() before the idle moment (replays off in the config) cancels the start.
*/
startIfNotRunning() {
if (this.stopFunction || this.pausedForCapture) {
if (this.stopFunction || this.pausedForCapture || this.cancelIdleStart) {
return;
}

this.start();
this.cancelIdleStart = runWhenIdle(() => {
this.cancelIdleStart = undefined;
if (this.stopFunction || this.pausedForCapture) {
return;
}
this.start();
}, IDLE_START_TIMEOUT_MS);
}

clearIdleStart() {
if (this.cancelIdleStart) {
this.cancelIdleStart();
this.cancelIdleStart = undefined;
}
}

/**
* Stop replays
* @returns
*/
stop() {
this.clearIdleStart();
if (this.stopFunction) {
this.stopFunction();
this.stopFunction = undefined;
Expand All @@ -241,6 +265,12 @@ export default class GleapReplayRecorder {
*/
pauseForCapture() {
if (!this.stopFunction) {
// About to start: it starts after the capture instead.
if (this.cancelIdleStart) {
this.clearIdleStart();
this.pausedForCapture = true;
return true;
}
return false;
}
try {
Expand Down
82 changes: 82 additions & 0 deletions src/GleapReplayRecorder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,85 @@ describe('approximateSize', () => {
expect(ratio).toBeLessThan(1.25);
});
});

// The first full snapshot serializes the whole page, which is most of the SDK's main-thread
// work on a large page: the eager start at init and the config's start wait for an idle moment.
describe('startIfNotRunning waits for an idle moment', () => {
let idleCallbacks;

beforeEach(() => {
idleCallbacks = [];
global.window.requestIdleCallback = jest.fn((callback, options) => {
idleCallbacks.push({ callback, options });
return idleCallbacks.length;
});
global.window.cancelIdleCallback = jest.fn((handle) => {
idleCallbacks[handle - 1].cancelled = true;
});
});
const runIdle = () => idleCallbacks.filter((entry) => !entry.cancelled).forEach((entry) => entry.callback());

test('records nothing until the browser is idle, then starts once', () => {
const recorder = new GleapReplayRecorder();
recorder.startIfNotRunning();
recorder.startIfNotRunning();

expect(record).not.toHaveBeenCalled();
expect(idleCallbacks).toHaveLength(1);
expect(idleCallbacks[0].options.timeout).toBeLessThanOrEqual(2000);

runIdle();
expect(record).toHaveBeenCalledTimes(1);
expect(recorder.startDate).toBeDefined();
});

test('a recorder that is already running is left as it is', () => {
const recorder = new GleapReplayRecorder();
recorder.start();
recorder.startIfNotRunning();

expect(idleCallbacks).toHaveLength(0);
expect(record).toHaveBeenCalledTimes(1);
});

test('stopped before the idle moment (replays off in the config) -> never records', () => {
const recorder = new GleapReplayRecorder();
recorder.startIfNotRunning();
recorder.stop();
runIdle();

expect(record).not.toHaveBeenCalled();
});

test('an explicit start before the idle moment is not followed by a second one', () => {
const recorder = new GleapReplayRecorder();
recorder.startIfNotRunning();
recorder.start();
runIdle();

expect(record).toHaveBeenCalledTimes(1);
});

test('a capture request before the idle moment -> no replay during the capture, the replay starts after it', () => {
const recorder = new GleapReplayRecorder();
recorder.startIfNotRunning();

expect(recorder.pauseForCapture()).toBe(true);
runIdle();
expect(record).not.toHaveBeenCalled();

recorder.resumeAfterCapture();
expect(record).toHaveBeenCalledTimes(1);
});

test('without requestIdleCallback (Safari) -> starts after the page has loaded, at the latest after the timeout', () => {
delete global.window.requestIdleCallback;
delete global.window.cancelIdleCallback;
const recorder = new GleapReplayRecorder();
recorder.startIfNotRunning();

expect(record).not.toHaveBeenCalled();
jest.advanceTimersByTime(2000);
expect(record).toHaveBeenCalledTimes(1);
});
});
Loading