import { SessionType, type ISession, type IUserAttributes, } from '@multiplayer-app/session-recorder-common' import { Observable } from 'lib0/observable' import { type eventWithTime } from '@rrweb/types' import { TracerReactNativeSDK } from './otel' import { RecorderReactNativeSDK } from './recorder' import { logger } from './utils' import { SessionState, type ISessionRecorder, type SessionRecorderConfigs, type SessionRecorderOptions, type EventRecorder, } from './types' import { SESSION_STOPPED_EVENT, REMOTE_SESSION_RECORDING_START, REMOTE_SESSION_RECORDING_STOP, SESSION_SAVE_BUFFER_EVENT, } from './config' import { getFormattedDate, isSessionActive, getNavigatorInfo } from './utils' import { setShouldRecordHttpData, setMaxCapturingHttpPayloadSize, } from './patch' import { BASE_CONFIG, getSessionRecorderConfig } from './config' import { StorageService } from './services/storage.service' import { NetworkService } from './services/network.service' import { CrashBufferService } from './services/crashBuffer.service' import { ApiService, type StartSessionRequest, type StopSessionRequest, } from './services/api.service' import { SocketService } from './services/socket.service' type SessionRecorderEvents = 'state-change' | 'init' class SessionRecorder extends Observable implements ISessionRecorder, EventRecorder { private _configs: SessionRecorderConfigs private _apiService = new ApiService() private _socketService = new SocketService() private _tracer = new TracerReactNativeSDK() private _recorder = new RecorderReactNativeSDK() private _storageService = StorageService.getInstance() private _networkService = NetworkService.getInstance() private _crashBuffer = CrashBufferService.getInstance() private _isFlushingBuffer: boolean = false private _startRequestController: AbortController | null = null // Whether the session recorder is initialized private _isInitialized = false get isInitialized(): boolean { return this._isInitialized } set isInitialized(isInitialized: boolean) { this._isInitialized = isInitialized } // Session ID and state are stored in AsyncStorage private _sessionId: string | null = null get sessionId(): string | null { return this._sessionId } set sessionId(sessionId: string | null) { this._sessionId = sessionId if (sessionId) { this._storageService.saveSessionId(sessionId) } } private _sessionType: SessionType = SessionType.MANUAL get sessionType(): SessionType { return this._sessionType } set sessionType(sessionType: SessionType) { this._sessionType = sessionType this._storageService.saveSessionType(sessionType) } get continuousRecording(): boolean { return this.sessionType === SessionType.CONTINUOUS } private _sessionState: SessionState | null = null get sessionState(): SessionState | null { return this._sessionState || SessionState.stopped } set sessionState(state: SessionState | null) { this._sessionState = state this.emit('state-change', [state || SessionState.stopped, this.sessionType]) if (state) { this._storageService.saveSessionState(state) } } private _session: ISession | null = null get session(): ISession | null { return this._session } set session(session: ISession | null) { this._session = session if (session) { this._storageService.saveSessionObject(session) } } private _sessionAttributes: Record | null = null get sessionAttributes(): Record { return this._sessionAttributes || {} } set sessionAttributes(attributes: Record | null) { this._sessionAttributes = attributes } private _userAttributes: IUserAttributes | null = null get userAttributes(): IUserAttributes | null { return this._userAttributes } set userAttributes(userAttributes: IUserAttributes | null) { this._userAttributes = userAttributes } /** * Error message getter and setter */ public get error(): string { return this._error || '' } public set error(v: string) { this._error = v } private _error: string = '' /** * React Native doesn't have HTML elements, so we return null */ public get sessionWidgetButtonElement(): any { return null } public get config(): SessionRecorderConfigs { return this._configs } /** * Initialize debugger with default or custom configurations */ constructor() { super() this._configs = BASE_CONFIG // Initialize with stored session data if available StorageService.initialize() } /** * Capture an exception manually and send it as an error trace. */ public captureException( error: unknown, errorInfo?: Record, ): void { try { const normalizedError = this._normalizeError(error) const normalizedErrorInfo = this._normalizeErrorInfo(errorInfo) this._tracer.captureException(normalizedError, normalizedErrorInfo) } catch (e: any) { this.error = e?.message || 'Failed to capture exception' } } private async _flushBuffer(sessionId: string): Promise { if ( !sessionId || !this._crashBuffer || this._isFlushingBuffer || !this._configs?.buffering?.enabled ) { return null } this._isFlushingBuffer = true try { const { events, spans, startedAt, stoppedAt } = await this._crashBuffer.snapshot() if (events.length === 0 && spans.length === 0) { return null } await Promise.all([ this._tracer.exportTraces(spans.map((s) => s.span)), this._apiService.exportEvents(sessionId, { events: events.map((e) => e.event), }), this._apiService.updateSessionAttributes(sessionId, { startedAt: this._toCrashBufferSessionIso(startedAt), stoppedAt: this._toCrashBufferSessionIso(stoppedAt), sessionAttributes: this.sessionAttributes, resourceAttributes: getNavigatorInfo(), userAttributes: this._userAttributes || undefined, }), ]) } catch (_e) { // swallow: flush is best-effort; never throw into app code } finally { await this._crashBuffer.clear() this._isFlushingBuffer = false } } private _toCrashBufferSessionIso(ts: number): string { return new Date(ts).toISOString() } private async _createExceptionSession(span: any): Promise { try { const session = await this._apiService.createErrorSession({ span }) if (session?._id) { void this._flushBuffer(session._id) } } catch (_ignored) { // best-effort } } private async _loadStoredSessionData(): Promise { try { await StorageService.initialize() const storedData = await this._storageService.getAllSessionData() if (isSessionActive(storedData.sessionObject, storedData.sessionType)) { this.session = storedData.sessionObject this.sessionId = storedData.sessionId this.sessionType = storedData.sessionType || SessionType.MANUAL this.sessionState = storedData.sessionState } else { this.session = null this.sessionId = null this.sessionState = null this.sessionType = SessionType.MANUAL } } catch (error) { logger.error( 'SessionRecorder', 'Failed to load stored session data', error, ) this.session = null this.sessionId = null this.sessionState = null this.sessionType = SessionType.MANUAL } } /** * Initialize the session debugger * @param configs - custom configurations for session debugger */ public async init(configs: SessionRecorderOptions): Promise { if (this._isInitialized) return this._isInitialized = true this._configs = getSessionRecorderConfig({ ...this._configs, ...configs }) logger.configure(this._configs.logger) await this._loadStoredSessionData() setMaxCapturingHttpPayloadSize(this._configs.maxCapturingHttpPayloadSize) setShouldRecordHttpData( this._configs.captureBody, this._configs.captureHeaders, ) // Crash buffer wiring (RN): set BEFORE tracer init so early spans don't get exported. const bufferEnabled = Boolean(this._configs.buffering?.enabled) const windowMs = Math.max( 10_000, (this._configs.buffering?.windowMinutes || 0.5) * 60 * 1000, ) if (bufferEnabled) { // Drop any buffer persisted by a previous app launch: the stored FullSnapshot // refers to a view hierarchy that no longer exists, and carrying old timestamps // forward inflates the reported session duration on flush. The CrashBufferService // serializes its ops via an internal chain, so appends triggered after this // clear are guaranteed to run afterwards. void this._crashBuffer.clear() } this._tracer.setCrashBuffer( bufferEnabled ? this._crashBuffer : undefined, windowMs, ) this._tracer.init(this._configs) this._apiService.init(this._configs) this._socketService.init({ apiKey: this._configs.apiKey, socketUrl: this._configs.apiBaseUrl, keepAlive: this._configs.useWebsocket, clientId: this._tracer.clientId, }) this._recorder.init( this._configs, this._socketService, bufferEnabled ? this._crashBuffer : undefined, { enabled: bufferEnabled, windowMs }, ) this._crashBuffer.on('error-span-appended', (payload) => { if ( !payload.span || this.sessionId || this.sessionState !== SessionState.stopped ) { return } this._createExceptionSession(payload.span) }) await this._networkService.init() this._setupNetworkCallbacks() this._registerSocketServiceListeners() if ( this.sessionId && (this.sessionState === SessionState.started || this.sessionState === SessionState.paused) ) { this._start() } else { this._startBufferOnlyRecording() } this.emit('init', []) } private _startBufferOnlyRecording(): void { // `this.sessionId` is intentionally NOT checked: `_stop()` runs before // `_clearSession()` (the stopSession API call sits between them), so the // buffer-restart chain fires while sessionId is still set. Bailing here // meant the recorder never restarted after manual stop, leaving no // FullSnapshot and silently breaking exception-triggered flushBuffer. if ( !this._crashBuffer || !this._configs?.buffering?.enabled || this.sessionState !== SessionState.stopped ) { return } const windowMs = Math.max( 10_000, (this._configs.buffering.windowMinutes || 0.5) * 60 * 1000, ) // Wire buffer into tracer + recorder (only used when sessionId is null). this._tracer.setCrashBuffer(this._crashBuffer, windowMs) this._recorder.init(this._configs, this._socketService, this._crashBuffer, { enabled: true, windowMs, }) // Start capturing events without an active debug session id. try { this._recorder.stop() // eslint-disable-next-line no-empty } catch (_e) {} this._recorder.start(null, SessionType.MANUAL) } /** * Register socket service event listeners */ private _registerSocketServiceListeners(): void { this._socketService.on(SESSION_STOPPED_EVENT, () => { this._stop() this._clearSession() }) this._socketService.on(REMOTE_SESSION_RECORDING_START, (payload: any) => { logger.info( 'SessionRecorder', 'Remote session recording started', payload, ) if (this.sessionState === SessionState.stopped) { this.start() } }) this._socketService.on(REMOTE_SESSION_RECORDING_STOP, (payload: any) => { logger.info( 'SessionRecorder', 'Remote session recording stopped', payload, ) if (this.sessionState !== SessionState.stopped) { this.stop() } }) this._socketService.on(SESSION_SAVE_BUFFER_EVENT, (payload: any) => { // Only honor backend save-buffer requests while in buffer-only mode. If a session // is already recording (manual/continuous), the client is streaming directly and // should ignore this event. if (this.sessionState !== SessionState.stopped) return this._flushBuffer(payload?.debugSession?._id) }) } /** * Setup network state change callbacks */ private _setupNetworkCallbacks(): void { this._networkService.addCallback((state) => { if (!state.isConnected && this.sessionState === SessionState.started) { logger.info( 'SessionRecorder', 'Network went offline - pausing session recording', ) this.pause() } else if ( state.isConnected && this.sessionState === SessionState.paused ) { logger.info( 'SessionRecorder', 'Network came back online - resuming session recording', ) this.resume() } }) } /** * Start a new session * @param type - the type of session to start * @param session - the session to start */ public async start( type: SessionType = SessionType.MANUAL, session?: ISession, ): Promise { this._checkOperation('start') // Check if offline - don't start recording if offline if (!this._networkService.isOnline()) { logger.warn( 'SessionRecorder', 'Cannot start session recording - device is offline', ) throw new Error('Cannot start session recording while offline') } // If continuous recording is disabled, force plain mode if ( type === SessionType.CONTINUOUS && !this._configs?.showContinuousRecording ) { type = SessionType.MANUAL } logger.info('SessionRecorder', 'Starting session with type:', type) this.sessionType = type this._startRequestController = new AbortController() if (session) { this._setupSessionAndStart(session, true) } else { await this._createSessionAndStart() } } /** * Stop the current session with an optional comment * @param comment - user-provided comment to include in session session attributes */ public async stop(comment?: string): Promise { try { this._checkOperation('stop') const sid = this.sessionId this._stop() if (this.continuousRecording) { await this._apiService.stopContinuousDebugSession(sid!) this.sessionType = SessionType.MANUAL } else { const request: StopSessionRequest = { sessionAttributes: { comment }, stoppedAt: Date.now(), } await this._apiService.stopSession(sid!, request) } } catch (error: any) { this.error = error.message } } /** * Pause the current session */ public async pause(): Promise { try { this._checkOperation('pause') this._pause() } catch (error: any) { this.error = error.message } } /** * Resume the current session */ public async resume(): Promise { try { this._checkOperation('resume') this._resume() } catch (error: any) { this.error = error.message } } /** * Cancel the current session */ public async cancel(): Promise { try { this._checkOperation('cancel') const sid = this.sessionId this._stop() if (this.continuousRecording) { await this._apiService.stopContinuousDebugSession(sid!) this.sessionType = SessionType.MANUAL } else { await this._apiService.cancelSession(sid!) } } catch (error: any) { this.error = error.message } } /** * Save the continuous recording session */ public async save(): Promise { try { this._checkOperation('save') if ( !this.continuousRecording || !this._configs?.showContinuousRecording ) { return } const res = await this._apiService.saveContinuousDebugSession( this.sessionId!, { sessionAttributes: this.sessionAttributes, resourceAttributes: getNavigatorInfo(), stoppedAt: Date.now(), name: this._getSessionName(), }, ) return res } catch (error: any) { this.error = error.message } } /** * Set the session attributes * @param attributes - the attributes to set */ public setSessionAttributes(attributes: Record): void { this._sessionAttributes = attributes } /** * Set the user attributes * @param userAttributes - the user attributes to set */ public setUserAttributes(userAttributes: IUserAttributes | null): void { if (!this._userAttributes && !userAttributes) { return } this._userAttributes = userAttributes const data = { userAttributes: this._userAttributes, clientId: this._tracer.clientId, } this._socketService.setUser(data) } /** * @description Check if session should be started/stopped automatically * @param {ISession} [sessionPayload] * @returns {Promise} */ public async checkRemoteContinuousSession( sessionPayload?: Omit, ): Promise { this._checkOperation('autoStartRemoteContinuousSession') if (!this._configs?.showContinuousRecording) { return } const payload = { sessionAttributes: { ...this.sessionAttributes, ...(sessionPayload?.sessionAttributes || {}), }, resourceAttributes: { ...getNavigatorInfo(), ...(sessionPayload?.resourceAttributes || {}), }, ...(this._userAttributes ? { 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() } } } /** * Create a new session and start it */ private async _createSessionAndStart(): Promise { const signal = this._startRequestController?.signal try { const payload = { sessionAttributes: this.sessionAttributes, resourceAttributes: getNavigatorInfo(), name: this._getSessionName(), ...(this._userAttributes ? { userAttributes: this._userAttributes } : {}), } const request: StartSessionRequest = !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: any) { this.error = error.message logger.error('SessionRecorder', 'Error creating session:', error.message) if (this.continuousRecording) { this.sessionType = SessionType.MANUAL } } } /** * Start tracing and recording for the session */ private _start(): void { this.sessionState = SessionState.started // eslint-disable-next-line no-self-assign this.sessionType = this.sessionType if (this.sessionId) { // Switch from buffer-only recording to session recording cleanly. try { this._recorder.stop() // eslint-disable-next-line no-empty } catch (_e) {} this._tracer.start(this.sessionId, this.sessionType) this._recorder.start(this.sessionId, this.sessionType) if (this.session) { this._socketService.subscribeToSession(this.session) } } } /** * Stop tracing and recording for the session */ private _stop(): void { this.sessionState = SessionState.stopped this._socketService.unsubscribeFromSession(true) this._tracer.stop() this._recorder.stop() // Clear session identity synchronously. The buffer-restart path and the // error-span-appended listener both gate on `this.sessionId === null`; // deferring this to `_clearSession()` (after the network stopSession // call) left them seeing a stale id and silently no-oping. Callers that // need the id for the stop/cancel API must capture it before _stop(). this.session = null this.sessionId = null // Each recorder start assigns fresh node IDs, so the next buffer // segment must not carry events from the previous generation. The // crash buffer's opChain queues this clear before any subsequent // appendEvent, so the fresh FullSnapshot can't be wiped. void this._crashBuffer?.clear() this._startBufferOnlyRecording() } /** * Pause the session tracing and recording */ private _pause(): void { this._tracer.stop() this._recorder.stop() this.sessionState = SessionState.paused } /** * Resume the session tracing and recording */ private _resume(): void { if (this.sessionId) { this._tracer.start(this.sessionId, this.sessionType) try { this._recorder.stop() // eslint-disable-next-line no-empty } catch (_e) {} this._recorder.start(this.sessionId, this.sessionType) } this.sessionState = SessionState.started } private _setupSessionAndStart( session: ISession, configureExporters: boolean = true, ): void { if (configureExporters && session.tempApiKey) { this._configs.apiKey = session.tempApiKey this._tracer.setApiKey(session.tempApiKey) this._apiService.setApiKey(session.tempApiKey) this._socketService.updateConfigs({ apiKey: session.tempApiKey }) } this._setSession(session) this._start() } /** * Set the session ID in storage * @param sessionId - the session ID to set or clear */ private _setSession(session: ISession): void { this.session = { ...session, createdAt: session.createdAt || new Date().toISOString(), } this.sessionId = session?.shortId || session?._id } private _clearSession(): void { this.session = null this.sessionId = null this.sessionState = SessionState.stopped this._storageService.clearSessionData() } /** * Check the operation validity based on the session state and action * @param action - action being checked ('init', 'start', 'stop', 'cancel', 'pause', 'resume') */ private _checkOperation( action: | 'init' | 'start' | 'stop' | 'cancel' | 'pause' | 'resume' | 'save' | 'autoStartRemoteContinuousSession', _payload?: any, ): void { 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 } } // Session attributes setSessionAttribute(key: string, value: any): void { if (this._session) { if (!this._session.sessionAttributes) { this._session.sessionAttributes = {} } this._session.sessionAttributes[key] = value this._session.updatedAt = new Date().toISOString() } } /** * Record a custom rrweb event * Note: Screen capture and touch events are recorded automatically when session is started * @param event - The rrweb event to record */ recordEvent(event: eventWithTime): void { if (!this._isInitialized || this.sessionState !== SessionState.started) { return } // Forward the event to the recorder SDK this._recorder.recordEvent(event) } /** * Set the viewshot ref for screen capture * @param ref - React Native View ref for screen capture */ setViewShotRef(ref: any): void { if (this._recorder) { this._recorder.setViewShotRef(ref) } } /** * Set the navigation ref for navigation tracking * @param ref - React Native Navigation ref for navigation tracking */ setNavigationRef(ref: any): void { if (this._recorder) { this._recorder.setNavigationRef(ref) } } /** * Cleanup resources and unsubscribe from network monitoring */ cleanup(): void { this._networkService.cleanup() } /** * Normalize an error to an Error object * @param error - the error to normalize * @returns the normalized error */ private _normalizeError(error: unknown): 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)) } } /** * Normalize an error info object to a Record * @param errorInfo - the error info to normalize * @returns the normalized error info */ private _normalizeErrorInfo( errorInfo?: Record, ): Record { if (!errorInfo) return {} try { return JSON.parse(JSON.stringify(errorInfo)) } catch (_e) { return { errorInfo: String(errorInfo) } } } /** * Get the session name * @returns the session name */ private _getSessionName(date: Date = new Date()): string { const userName = this.sessionAttributes?.userName || this._userAttributes?.userName || this._userAttributes?.name || '' return userName ? `${userName}'s session on ${getFormattedDate(date, { month: 'short', day: 'numeric' })}` : `Session on ${getFormattedDate(date)}` } } export default new SessionRecorder()