@perceptr/web-sdk
Version:
Perceptr Web SDK for recording and monitoring user sessions
238 lines (237 loc) • 9.72 kB
JavaScript
import { EventType, record } from "rrweb";
import { getRecordConsolePlugin } from "@rrweb/rrweb-plugin-console-record";
import { ACTIVE_SOURCES, INCREMENTAL_SNAPSHOT_EVENT_TYPE, } from "./common/defaults";
import { sessionRecordingUrlTriggerMatches } from "./utils/sessionrecording-utils";
import { MutationRateLimiter } from "./common/services/MutationRateLimiter";
import { logger } from "./utils/logger";
export class SessionRecorder {
constructor(config = {}) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y;
this.events = [];
this._isRecording = false;
this._isPaused = false;
this._isUrlBlocked = false;
this.config = {
staleThreshold: (_a = config.staleThreshold) !== null && _a !== void 0 ? _a : 3600000, // 1 hour default
console: {
lengthThreshold: (_c = (_b = config.console) === null || _b === void 0 ? void 0 : _b.lengthThreshold) !== null && _c !== void 0 ? _c : 1000,
level: (_e = (_d = config.console) === null || _d === void 0 ? void 0 : _d.level) !== null && _e !== void 0 ? _e : ["log", "info", "warn", "error"],
logger: (_g = (_f = config.console) === null || _f === void 0 ? void 0 : _f.logger) !== null && _g !== void 0 ? _g : "console",
stringifyOptions: (_j = (_h = config.console) === null || _h === void 0 ? void 0 : _h.stringifyOptions) !== null && _j !== void 0 ? _j : {
stringLengthLimit: 1000,
numOfKeysLimit: 100,
depthOfLimit: 10,
},
},
urlBlocklist: (_k = config.urlBlocklist) !== null && _k !== void 0 ? _k : [],
maxEvents: (_l = config.maxEvents) !== null && _l !== void 0 ? _l : 10000,
sampling: {
mousemove: (_o = (_m = config.sampling) === null || _m === void 0 ? void 0 : _m.mousemove) !== null && _o !== void 0 ? _o : 50,
scroll: (_q = (_p = config.sampling) === null || _p === void 0 ? void 0 : _p.scroll) !== null && _q !== void 0 ? _q : 50,
input: (_s = (_r = config.sampling) === null || _r === void 0 ? void 0 : _r.input) !== null && _s !== void 0 ? _s : "all",
},
blockClass: (_t = config.blockClass) !== null && _t !== void 0 ? _t : "perceptr-block",
ignoreClass: (_u = config.ignoreClass) !== null && _u !== void 0 ? _u : "perceptr-ignore",
maskTextClass: (_v = config.maskTextClass) !== null && _v !== void 0 ? _v : "perceptr-mask",
blockSelector: (_w = config.blockSelector) !== null && _w !== void 0 ? _w : "",
maskTextSelector: (_x = config.maskTextSelector) !== null && _x !== void 0 ? _x : "",
idleTimeout: (_y = config.idleTimeout) !== null && _y !== void 0 ? _y : 10000, // 10 seconds default
};
// Internal mutation throttling configuration
this._mutationConfig = {
enabled: true,
bucketSize: 100,
refillRate: 10,
};
// Initialize the mutation rate limiter
this.mutationRateLimiter = new MutationRateLimiter(record, {
bucketSize: this._mutationConfig.bucketSize,
refillRate: this._mutationConfig.refillRate,
onBlockedNode: (id, node) => {
logger.debug(`Throttling mutations for node ${id}`, node);
},
});
}
startSession() {
var _a, _b, _c, _d;
if (this._isRecording) {
return;
}
this.stopFn = record({
emit: (event) => {
// Apply mutation rate limiting before processing the event
if (this._mutationConfig.enabled) {
const throttledEvent = this.mutationRateLimiter.throttleMutations(event);
// If the event was completely throttled, don't process it
if (throttledEvent)
return;
}
this._canAddEvent(event);
},
checkoutEveryNms: 10000, // takes a snapshot every 10 seconds event 2
plugins: [
// event type === '6' is console log
getRecordConsolePlugin({
lengthThreshold: (_a = this.config.console) === null || _a === void 0 ? void 0 : _a.lengthThreshold,
level: (_b = this.config.console) === null || _b === void 0 ? void 0 : _b.level,
logger: (_c = this.config.console) === null || _c === void 0 ? void 0 : _c.logger,
stringifyOptions: (_d = this.config.console) === null || _d === void 0 ? void 0 : _d.stringifyOptions,
}),
],
sampling: this.config.sampling,
blockClass: this.config.blockClass,
ignoreClass: this.config.ignoreClass,
maskTextClass: this.config.maskTextClass,
blockSelector: this.config.blockSelector,
maskTextSelector: this.config.maskTextSelector,
inlineStylesheet: true,
recordCrossOriginIframes: true,
});
this._isRecording = true;
this._isPaused = false;
this._resetIdleTimeout();
}
stopSession() {
if (!this._isRecording) {
logger.warn("No active recording session");
return;
}
if (this.stopFn) {
this.stopFn();
}
this.events = [];
this._isRecording = false;
this._isPaused = false;
if (this._idleTimeout) {
clearTimeout(this._idleTimeout);
}
}
pause() {
if (!this._isRecording || this._isPaused) {
return;
}
this._isPaused = true;
logger.debug("Recording paused");
}
resume() {
if (!this._isRecording || !this._isPaused) {
return;
}
this._isPaused = false;
logger.debug("Recording resumed");
}
_canAddEvent(event) {
// If the event is an interactive event, resume the recording
// otherwise the idle timeout will pause the recording
if (this._isInteractiveEvent(event) && !this._isUrlBlocked) {
this._resetIdleTimeout();
this.resume();
}
// If the recording is paused, don't add the event
if (this._isRecording && this._isPaused) {
return false;
}
// Handle page view events
if (event.type === EventType.Meta) {
this._checkMetaEvent(event);
}
else {
this._pageViewFallBack();
}
this.events.push(event);
if (this.events.length > this.config.maxEvents) {
this.events.shift();
}
return true;
}
_checkMetaEvent(event) {
const href = event.data.href;
if (!href) {
return false;
}
this._lastHref = href;
this._shouldBlockUrl(href);
}
_pageViewFallBack() {
if (typeof window === "undefined" || !window.location.href) {
return;
}
const currentUrl = window.location.href;
if (this._lastHref !== currentUrl) {
this._lastHref = currentUrl;
this.addCustomEvent("$url_changed", { href: currentUrl });
this._shouldBlockUrl(currentUrl);
}
}
_shouldBlockUrl(url) {
if (typeof window === "undefined" || !window.location.href) {
return;
}
const isNowBlocked = sessionRecordingUrlTriggerMatches(url, this.config.urlBlocklist);
this._isUrlBlocked = isNowBlocked;
if (isNowBlocked && !this._isPaused) {
this.pause();
}
else if (!isNowBlocked && this._isPaused) {
this.resume();
}
}
_resetIdleTimeout() {
if (this._idleTimeout) {
clearTimeout(this._idleTimeout);
}
this._idleTimeout = setTimeout(() => {
if (this._isRecording && !this._isPaused) {
this.pause();
this._idleTimeout = undefined;
}
}, this.config.idleTimeout);
}
_isInteractiveEvent(event) {
var _a;
return (event.type === INCREMENTAL_SNAPSHOT_EVENT_TYPE &&
ACTIVE_SOURCES.indexOf((_a = event.data) === null || _a === void 0 ? void 0 : _a.source) !== -1);
}
/**
* Get the recording events
* @returns The recording events
*/
getRecordingEvents() {
if (!this._isRecording) {
throw new Error("No active recording session");
}
return this.events;
}
onEvent(callback) {
const originalAddEvent = this._canAddEvent;
this._canAddEvent = (event) => {
const canBeAdded = originalAddEvent.call(this, event);
if (canBeAdded) {
// if the event can be added, call the callback to add it to the buffer
callback(event);
}
return canBeAdded;
};
// Return a function to unsubscribe
return () => {
this._canAddEvent = originalAddEvent;
};
}
/**
* Add a custom event to the recording
* @param name - Event name
* @param payload - Event data
*/
addCustomEvent(name, payload) {
if (!this._isRecording) {
logger.warn("Cannot add custom event: No active recording session");
return;
}
try {
record.addCustomEvent(name, payload);
}
catch (error) {
logger.error(`Failed to add custom event: ${name}`, error);
}
}
}