@multiplayer-app/session-recorder-browser
Version:
Multiplayer Fullstack Session Recorder for Browser
695 lines • 26.2 kB
JavaScript
import { Observable } from 'lib0/observable';
import { SessionType, } from '@multiplayer-app/session-recorder-common';
import { TracerBrowserSDK } from './otel';
import { RecorderBrowserSDK } from './rrweb';
import { getStoredItem, setStoredItem, getNavigatorInfo, getFormattedDate, getTimeDifferenceInSeconds, isSessionActive } from './utils';
import { SessionState, } from './types';
import { SocketService } from './services/socket.service';
import { BASE_CONFIG, SESSION_RESPONSE, SESSION_PROP_NAME, SESSION_ID_PROP_NAME, SESSION_TYPE_PROP_NAME, SESSION_STATE_PROP_NAME, DEFAULT_MAX_HTTP_CAPTURING_PAYLOAD_SIZE, getSessionRecorderConfig, SESSION_AUTO_CREATED, SESSION_STOPPED_EVENT, SESSION_STARTED_EVENT, REMOTE_SESSION_RECORDING_START, REMOTE_SESSION_RECORDING_STOP } from './config';
import { setShouldRecordHttpData, setMaxCapturingHttpPayloadSize, } from './patch';
import { recorderEventBus } from './eventBus';
import { SessionWidget } from './sessionWidget';
import messagingService from './services/messaging.service';
import { ApiService } from './services/api.service';
import { ContinuousRecordingSaveButtonState } from './sessionWidget/buttonStateConfigs';
import { NavigationRecorder } from './navigation';
export class SessionRecorder extends Observable {
get navigation() {
return this._navigationRecorder.api;
}
get isInitialized() {
return this._isInitialized;
}
get sessionId() {
return this._sessionId;
}
set sessionId(sessionId) {
this._sessionId = sessionId;
setStoredItem(SESSION_ID_PROP_NAME, sessionId);
}
get sessionType() {
return this._sessionType;
}
set sessionType(sessionType) {
this._sessionType = sessionType;
const continuousRecording = sessionType === SessionType.CONTINUOUS;
this._sessionWidget.updateContinuousRecordingState(continuousRecording);
messagingService.sendMessage('continuous-debugging', continuousRecording);
setStoredItem(SESSION_TYPE_PROP_NAME, sessionType);
}
get continuousRecording() {
return this.sessionType === SessionType.CONTINUOUS;
}
get sessionState() {
return this._sessionState || SessionState.stopped;
}
set sessionState(state) {
this._sessionState = state;
this._sessionWidget.updateState(this._sessionState, this.continuousRecording);
messagingService.sendMessage('state-change', this._sessionState);
setStoredItem(SESSION_STATE_PROP_NAME, state);
// Emit observable event to support React wrapper
this.emit('state-change', [this._sessionState || SessionState.stopped, this.sessionType]);
}
get session() {
return this._session;
}
set session(session) {
this._session = session;
setStoredItem(SESSION_PROP_NAME, this._session);
}
get sessionAttributes() {
return this._sessionAttributes || {};
}
set sessionAttributes(attributes) {
this._sessionAttributes = attributes;
}
get userAttributes() {
return this._userAttributes;
}
set userAttributes(userAttributes) {
this._userAttributes = userAttributes;
}
get error() {
return this._error;
}
set error(v) {
this._error = v;
this._sessionWidget.error = v;
this.emit('error', [v]);
}
/**
* Returns the HTML button element for the session widget's recorder button.
*
* This element is used to control the start/stop recording functionality in the session widget UI.
*
* @returns {HTMLButtonElement | null} The recorder button element from the session widget.
*/
get sessionWidgetButtonElement() {
return this._sessionWidget.recorderButton;
}
/**
* Initialize debugger with default or custom configurations
*/
constructor() {
var _a;
super();
this._apiService = new ApiService();
this._socketService = new SocketService();
this._tracer = new TracerBrowserSDK();
this._recorder = new RecorderBrowserSDK();
this._sessionWidget = new SessionWidget();
this._navigationRecorder = new NavigationRecorder();
this._startRequestController = null;
this._isInitialized = false;
// Session ID and state are stored in localStorage
this._sessionId = null;
this._sessionType = SessionType.MANUAL;
this._sessionState = null;
this._session = null;
this._sessionAttributes = null;
this._userAttributes = null;
/**
* Error message getter and setter
*/
this._error = '';
// Safety: avoid accessing storage in SSR/non-browser environments
const isBrowser = typeof window !== 'undefined';
const sessionLocal = isBrowser ? getStoredItem(SESSION_PROP_NAME, true) : null;
const sessionIdLocal = isBrowser ? getStoredItem(SESSION_ID_PROP_NAME) : null;
const sessionStateLocal = isBrowser ? getStoredItem(SESSION_STATE_PROP_NAME) : null;
const sessionTypeLocal = isBrowser ? getStoredItem(SESSION_TYPE_PROP_NAME) : null;
if (isSessionActive(sessionLocal, sessionTypeLocal)) {
this.session = sessionLocal;
this.sessionId = sessionIdLocal;
this.sessionType = sessionTypeLocal;
this.sessionState = sessionStateLocal;
}
else {
this.session = null;
this.sessionId = null;
this.sessionState = null;
this.sessionType = SessionType.MANUAL;
}
this._configs = {
...BASE_CONFIG,
apiKey: ((_a = this.session) === null || _a === void 0 ? void 0 : _a.tempApiKey) || '',
};
}
/**
* Initialize the session debugger
* @param configs - custom configurations for session debugger
*/
init(configs) {
if (typeof window === 'undefined') {
return;
}
this._configs = getSessionRecorderConfig({ ...this._configs, ...configs });
this._isInitialized = true;
this._checkOperation('init');
setMaxCapturingHttpPayloadSize(this._configs.maxCapturingHttpPayloadSize || DEFAULT_MAX_HTTP_CAPTURING_PAYLOAD_SIZE);
setShouldRecordHttpData(this._configs.captureBody, this._configs.captureHeaders);
this._tracer.init(this._configs);
this._apiService.init(this._configs);
this._sessionWidget.init(this._configs);
this._socketService.init({
apiKey: this._configs.apiKey,
socketUrl: this._configs.apiBaseUrl || '',
keepAlive: Boolean(this._configs.useWebsocket),
usePostMessageFallback: Boolean(this._configs.usePostMessageFallback),
});
this._navigationRecorder.init({
version: this._configs.version,
application: this._configs.application,
environment: this._configs.environment,
enabled: this._configs.recordNavigation,
});
if (this._configs.apiKey) {
this._recorder.init(this._configs, this._socketService);
}
if (this.sessionId
&& (this.sessionState === SessionState.started
|| this.sessionState === SessionState.paused)) {
this._start();
}
this._registerWidgetEvents();
this._registerSocketServiceListeners();
messagingService.sendMessage('state-change', this.sessionState);
// Emit init observable event
this.emit('init', [this]);
}
/**
* Save the continuous recording session
*/
async save() {
try {
this._checkOperation('save');
if (!this.continuousRecording || !this._configs.showContinuousRecording) {
return;
}
this._sessionWidget.updateSaveContinuousDebugSessionState(ContinuousRecordingSaveButtonState.SAVING);
const res = await this._apiService.saveContinuousDebugSession(this.sessionId, {
sessionAttributes: this.sessionAttributes,
resourceAttributes: getNavigatorInfo(),
stoppedAt: this._recorder.stoppedAt,
name: this._getSessionName()
});
this._sessionWidget.updateSaveContinuousDebugSessionState(ContinuousRecordingSaveButtonState.SAVED);
const sessionUrl = res === null || res === void 0 ? void 0 : res.url;
this._sessionWidget.showToast({
type: 'success',
message: 'Your session was saved',
button: {
text: 'Open session', url: sessionUrl,
},
}, 5000);
return res;
}
catch (error) {
this.error = error.message;
this._sessionWidget.updateSaveContinuousDebugSessionState(ContinuousRecordingSaveButtonState.ERROR);
}
finally {
setTimeout(() => {
this._sessionWidget.updateSaveContinuousDebugSessionState(ContinuousRecordingSaveButtonState.IDLE);
}, 3000);
}
}
/**
* Start a new session
* @param type - the type of session to start
* @param session - the session to start
*/
start(type = SessionType.MANUAL, session) {
this._checkOperation('start');
// If continuous recording is disabled, force plain mode
if (type === SessionType.CONTINUOUS && !this._configs.showContinuousRecording) {
type = SessionType.MANUAL;
}
this.sessionType = type;
this._startRequestController = new AbortController();
if (session) {
this._setupSessionAndStart(session, true);
}
else {
this._createSessionAndStart();
}
}
/**
* Stop the current session with an optional comment
* @param comment - user-provided comment to include in session session attributes
*/
async stop(comment) {
try {
this._checkOperation('stop');
this._stop();
if (this.continuousRecording) {
await this._apiService.stopContinuousDebugSession(this.sessionId);
this.sessionType = SessionType.MANUAL;
}
else {
const request = {
sessionAttributes: { comment },
stoppedAt: this._recorder.stoppedAt,
};
const response = await this._apiService.stopSession(this.sessionId, request);
recorderEventBus.emit(SESSION_RESPONSE, response);
}
this._clearSession();
}
catch (error) {
this.error = error.message;
}
}
/**
* Pause the current session
*/
async pause() {
try {
this._checkOperation('pause');
this._pause();
}
catch (error) {
this.error = error.message;
}
}
/**
* Resume the current session
*/
async resume() {
try {
this._checkOperation('resume');
this._resume();
}
catch (error) {
this.error = error.message;
}
}
/**
* Cancel the current session
*/
async cancel() {
try {
this._checkOperation('cancel');
this._stop();
if (this.continuousRecording) {
await this._apiService.stopContinuousDebugSession(this.sessionId);
this.sessionType = SessionType.MANUAL;
}
else {
await this._apiService.cancelSession(this.sessionId);
}
this._clearSession();
}
catch (error) {
this.error = error.message;
}
}
/**
* Set the session attributes
* @param attributes - the attributes to set
*/
setSessionAttributes(attributes) {
this._sessionAttributes = attributes;
}
/**
* Set the user attributes
* @param userAttributes - the user attributes to set
*/
setUserAttributes(userAttributes) {
if (!this._userAttributes && !userAttributes) {
return;
}
this._userAttributes = userAttributes;
this._socketService.setUser(this._userAttributes);
}
/**
* Updates the button click handler in the library.
* @param handler - A function that will be invoked when the button is clicked.
* The function receives the click event as its parameter and
* should return `false` to prevent the default button action,
* or `true` (or nothing) to allow it.
*/
set recordingButtonClickHandler(handler) {
this._sessionWidget.buttonClickExternalHandler = handler;
}
/**
* Capture an exception manually and send it as an error trace.
*/
captureException(error, errorInfo) {
try {
const normalizedError = this._normalizeError(error);
const normalizedErrorInfo = this._normalizeErrorInfo(errorInfo);
this._tracer.captureException(normalizedError, normalizedErrorInfo);
}
catch (e) {
this.error = (e === null || e === void 0 ? void 0 : e.message) || 'Failed to capture exception';
}
}
/**
* @description Check if session should be started/stopped automatically
* @param {ISession} [sessionPayload]
* @returns {Promise<void>}
*/
async checkRemoteContinuousSession(sessionPayload) {
this._checkOperation('autoStartRemoteContinuousSession');
if (!this._configs.showContinuousRecording) {
return;
}
const payload = {
sessionAttributes: {
...this.sessionAttributes,
...((sessionPayload === null || sessionPayload === void 0 ? void 0 : sessionPayload.sessionAttributes) || {}),
},
resourceAttributes: {
...getNavigatorInfo(),
...((sessionPayload === null || sessionPayload === void 0 ? void 0 : sessionPayload.resourceAttributes) || {}),
},
userAttributes: this._userAttributes
};
const { state } = await this._apiService.checkRemoteSession(payload);
if (state == 'START') {
if (this.sessionState !== SessionState.started) {
await this.start(SessionType.CONTINUOUS);
}
}
else if (state == 'STOP') {
if (this.sessionState !== SessionState.stopped) {
await this.stop();
}
}
}
/**
* Register session widget event listeners for controlling session actions
*/
_registerWidgetEvents() {
this._sessionWidget.on('start', () => {
this.error = '';
this._handleStart();
});
this._sessionWidget.on('stop', (comment) => {
this.error = '';
this._handleStop(comment);
});
this._sessionWidget.on('pause', () => {
this.error = '';
this._handlePause();
});
this._sessionWidget.on('resume', () => {
this.error = '';
this._handleResume();
});
this._sessionWidget.on('cancel', () => {
this.error = '';
this._handleCancel();
});
this._sessionWidget.on('continuous-debugging', (enabled) => {
this.error = '';
if (enabled) {
this._handleContinuousDebugging();
}
else {
this._handleStop();
}
});
this._sessionWidget.on('save', () => {
this.error = '';
this._handleSave();
});
}
/**
* Handle the safe start event
*/
_handleStart() {
if (this.sessionState === SessionState.stopped) {
this.start(SessionType.MANUAL);
}
}
/**
* Handle the safe stop event
*/
_handleStop(comment) {
if (this.sessionState === SessionState.started || this.sessionState === SessionState.paused) {
this.stop(comment);
}
}
/**
* Handle the safe pause event
*/
_handlePause() {
if (this.sessionState === SessionState.started) {
this.pause();
}
}
/**
* Handle the safe resume event
*/
_handleResume() {
if (this.sessionState === SessionState.paused) {
this.resume();
}
}
/**
* Handle the safe cancel event
*/
_handleCancel() {
if (this.sessionState === SessionState.started || this.sessionState === SessionState.paused) {
this.cancel();
}
}
/**
* Handle the safe save event
*/
_handleSave() {
if (this.sessionState === SessionState.started && this.continuousRecording) {
this.save();
}
}
/**
* Handle the safe continuous debugging event
*/
_handleContinuousDebugging() {
if (this.sessionState === SessionState.stopped) {
this.start(SessionType.CONTINUOUS);
}
}
/**
* Register socket service event listeners
*/
_registerSocketServiceListeners() {
this._socketService.on(SESSION_STOPPED_EVENT, () => {
this._stop();
this._clearSession();
this._sessionWidget.handleUIReseting();
});
this._socketService.on(SESSION_AUTO_CREATED, (payload) => {
var _a;
if (!(payload === null || payload === void 0 ? void 0 : payload.data))
return;
this._sessionWidget.showToast({
type: 'success',
message: 'Your session was auto-saved due to an error',
button: {
text: 'Open session',
url: (_a = payload === null || payload === void 0 ? void 0 : payload.data) === null || _a === void 0 ? void 0 : _a.url,
},
}, 5000);
});
this._socketService.on(REMOTE_SESSION_RECORDING_START, (payload) => {
console.log('REMOTE_SESSION_RECORDING_START', payload);
if (this.sessionState === SessionState.stopped) {
this.start();
}
});
this._socketService.on(REMOTE_SESSION_RECORDING_STOP, (payload) => {
console.log('REMOTE_SESSION_RECORDING_STOP', payload);
if (this.sessionState !== SessionState.stopped) {
this.stop();
}
});
}
/**
* Create a new session and start it
*/
async _createSessionAndStart() {
var _a;
const signal = (_a = this._startRequestController) === null || _a === void 0 ? void 0 : _a.signal;
try {
const payload = {
sessionAttributes: this.sessionAttributes,
resourceAttributes: getNavigatorInfo(),
name: this._getSessionName(),
...(this._userAttributes ? { userAttributes: this._userAttributes } : {}),
};
const request = !this.continuousRecording ?
payload : { debugSessionData: payload };
const session = this.continuousRecording
? await this._apiService.startContinuousDebugSession(request, signal)
: await this._apiService.startSession(request, signal);
if (session) {
session.sessionType = this.continuousRecording
? SessionType.CONTINUOUS
: SessionType.MANUAL;
this._setupSessionAndStart(session, false);
}
}
catch (error) {
this.error = error.message;
this.sessionState = SessionState.stopped;
if (this.continuousRecording) {
this.sessionType = SessionType.MANUAL;
}
}
}
/**
* Start tracing and recording for the session
*/
_start() {
var _a;
this.sessionState = SessionState.started;
this.sessionType = this.sessionType;
this._tracer.start(this.sessionId, this.sessionType);
this._recorder.start(this.sessionId, this.sessionType);
this._navigationRecorder.start({ sessionId: this.sessionId, sessionType: this.sessionType, });
if (this.session) {
recorderEventBus.emit(SESSION_STARTED_EVENT, this.session);
this._socketService.subscribeToSession(this.session);
this._sessionWidget.seconds = getTimeDifferenceInSeconds((_a = this.session) === null || _a === void 0 ? void 0 : _a.startedAt);
}
}
/**
* Stop tracing and recording for the session
*/
_stop() {
this.sessionState = SessionState.stopped;
this._socketService.unsubscribeFromSession(true);
this._tracer.stop();
this._recorder.stop();
this._navigationRecorder.stop();
}
/**
* Pause the session tracing and recording
*/
_pause() {
this._tracer.stop();
this._recorder.stop();
this._navigationRecorder.pause();
this.sessionState = SessionState.paused;
}
/**
* Resume the session tracing and recording
*/
_resume() {
this._tracer.start(this.sessionId, this.sessionType);
this._recorder.start(this.sessionId, this.sessionType);
this._navigationRecorder.resume();
this.sessionState = SessionState.started;
}
_setupSessionAndStart(session, configureExporters = true) {
if (configureExporters && session.tempApiKey) {
this._configs.apiKey = session.tempApiKey;
this._socketService.updateConfigs({ apiKey: this._configs.apiKey });
this._recorder.init(this._configs, this._socketService);
this._tracer.setApiKey(session.tempApiKey);
this._apiService.updateConfigs({ apiKey: this._configs.apiKey });
}
this._setSession(session);
this._start();
}
/**
* Set the session ID in localStorage
* @param sessionId - the session ID to set or clear
*/
_setSession(session) {
this.session = { ...session, startedAt: session.startedAt || new Date().toISOString() };
this.sessionId = (session === null || session === void 0 ? void 0 : session.shortId) || (session === null || session === void 0 ? void 0 : session._id);
}
_clearSession() {
this.session = null;
this.sessionId = null;
this.sessionState = SessionState.stopped;
}
/**
* Check the operation validity based on the session state and action
* @param action - action being checked ('init', 'start', 'stop', 'cancel', 'pause', 'resume')
*/
_checkOperation(action, payload) {
if (!this._isInitialized) {
throw new Error('Configuration not initialized. Call init() before performing any actions.');
}
switch (action) {
case 'start':
if (this.sessionState === SessionState.started) {
throw new Error('Session is already started.');
}
break;
case 'stop':
if (this.sessionState !== SessionState.paused && this.sessionState !== SessionState.started) {
throw new Error('Cannot stop. Session is not currently started.');
}
break;
case 'cancel':
if (this.sessionState === SessionState.stopped) {
throw new Error('Cannot cancel. Session has already been stopped.');
}
break;
case 'pause':
if (this.sessionState !== SessionState.started) {
throw new Error('Cannot pause. Session is not running.');
}
break;
case 'resume':
if (this.sessionState !== SessionState.paused) {
throw new Error('Cannot resume. Session is not paused.');
}
break;
case 'save':
if (!this.continuousRecording) {
throw new Error('Cannot save continuous recording session. Continuous recording is not enabled.');
}
if (this.sessionState !== SessionState.started) {
throw new Error('Cannot save continuous recording session. Session is not started.');
}
break;
case 'autoStartRemoteContinuousSession':
if (this.sessionState !== SessionState.stopped) {
throw new Error('Cannot start remote continuous session. Session is not stopped.');
}
break;
}
}
_normalizeError(error) {
if (error instanceof Error)
return error;
if (typeof error === 'string')
return new Error(error);
try {
return new Error(JSON.stringify(error));
}
catch (_e) {
return new Error(String(error));
}
}
_normalizeErrorInfo(errorInfo) {
if (!errorInfo)
return {};
try {
return JSON.parse(JSON.stringify(errorInfo));
}
catch (_e) {
return { errorInfo: String(errorInfo) };
}
}
/**
* Get the session name
* @returns the session name
*/
_getSessionName(date = new Date()) {
var _a, _b, _c;
const userName = ((_a = this.sessionAttributes) === null || _a === void 0 ? void 0 : _a.userName) || ((_b = this._userAttributes) === null || _b === void 0 ? void 0 : _b.userName) || ((_c = this._userAttributes) === null || _c === void 0 ? void 0 : _c.name) || '';
return userName
? `${userName}'s session on ${getFormattedDate(date, { month: 'short', day: 'numeric' })}`
: `Session on ${getFormattedDate(date)}`;
}
}
//# sourceMappingURL=session-recorder.js.map