UNPKG

@stacksleuth/session-replay

Version:

Advanced session replay and user interaction recording agent - DOM event capture, user journey tracking, performance correlation, and comprehensive session analytics.

618 lines 22.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.sessionReplayAgent = exports.SessionReplayAgent = void 0; const core_1 = require("@stacksleuth/core"); class SessionReplayAgent { constructor(config) { this.isActive = false; this.events = []; this.observers = {}; this.originalMethods = new Map(); this.eventBuffer = []; this.maxEvents = 10000; this.maxBufferSize = 100; this.profiler = new core_1.ProfilerCore(config); this.sessionId = this.generateSessionId(); this.maxEvents = config?.maxEvents || 10000; this.maxBufferSize = config?.maxBufferSize || 100; this.metadata = { sessionId: this.sessionId, userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'Node.js', url: typeof window !== 'undefined' ? window.location.href : 'N/A', referrer: typeof document !== 'undefined' ? document.referrer : 'N/A', viewport: this.getViewportSize(), startTime: Date.now(), eventCount: 0, errorCount: 0 }; // Auto-flush if enabled if (config?.autoFlush !== false) { this.flushInterval = setInterval(() => { this.flush(); }, config?.flushInterval || 5000); } } /** * Initialize the session replay agent */ async init() { if (this.isActive || typeof window === 'undefined') return; this.isActive = true; await this.profiler.init(); // Setup tracking this.setupUserInteractionTracking(); this.setupDOMTracking(); this.setupNetworkTracking(); this.setupPerformanceTracking(); this.setupConsoleTracking(); this.setupErrorTracking(); // Record initial page state this.recordEvent({ type: 'performance', data: { type: 'session_start', metadata: this.metadata } }); console.log('✅ Session Replay Agent initialized'); } /** * Setup user interaction tracking */ setupUserInteractionTracking() { const events = ['click', 'scroll', 'input', 'focus', 'blur', 'keypress', 'resize']; events.forEach(eventType => { const handler = (event) => { this.recordUserInteraction(eventType, event); }; document.addEventListener(eventType, handler, { passive: true, capture: true }); this.observers[eventType] = handler; }); // Special handling for scroll with throttling let scrollTimeout; const scrollHandler = (event) => { clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { this.recordUserInteraction('scroll', event); }, 100); }; document.addEventListener('scroll', scrollHandler, { passive: true }); this.observers['scroll_throttled'] = scrollHandler; } /** * Setup DOM mutation tracking */ setupDOMTracking() { if (!window.MutationObserver) return; const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { this.recordDOMChange(mutation); }); }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeOldValue: true, characterData: true, characterDataOldValue: true }); this.observers['mutation'] = observer; } /** * Setup network request tracking */ setupNetworkTracking() { // Intercept fetch const originalFetch = window.fetch; this.originalMethods.set('fetch', originalFetch); window.fetch = async (input, init) => { const startTime = Date.now(); const url = typeof input === 'string' ? input : input.toString(); const method = init?.method || 'GET'; try { const response = await originalFetch(input, init); this.recordNetworkRequest({ url, method, status: response.status, duration: Date.now() - startTime, requestSize: this.estimateRequestSize(init), responseSize: this.estimateResponseSize(response) }); return response; } catch (error) { this.recordNetworkRequest({ url, method, duration: Date.now() - startTime, requestSize: this.estimateRequestSize(init), responseSize: 0, error: error instanceof Error ? error.message : String(error) }); throw error; } }; // Intercept XMLHttpRequest const originalXHROpen = XMLHttpRequest.prototype.open; const originalXHRSend = XMLHttpRequest.prototype.send; this.originalMethods.set('xhr_open', originalXHROpen); this.originalMethods.set('xhr_send', originalXHRSend); XMLHttpRequest.prototype.open = function (method, url, ...args) { this._stacksleuth = { method, url: url.toString(), startTime: Date.now() }; return originalXHROpen.apply(this, [method, url, ...args]); }; XMLHttpRequest.prototype.send = function (body) { const xhr = this; const data = xhr._stacksleuth; if (data) { const originalOnReadyStateChange = xhr.onreadystatechange; xhr.onreadystatechange = function () { if (xhr.readyState === 4) { const networkEvent = { url: data.url, method: data.method, status: xhr.status, duration: Date.now() - data.startTime, requestSize: body ? JSON.stringify(body).length : 0, responseSize: xhr.responseText?.length || 0 }; if (xhr.status >= 400) { networkEvent.error = `HTTP ${xhr.status}`; } // Access the outer scope SessionReplayAgent instance // This is a bit tricky, but we'll use a global reference window._stacksleuth_replay_agent?.recordNetworkRequest(networkEvent); } if (originalOnReadyStateChange) { // Create a proper event for the callback const event = new Event('readystatechange'); originalOnReadyStateChange.call(xhr, event); } }; } return originalXHRSend.apply(this, [body]); }; // Store reference for XHR tracking window._stacksleuth_replay_agent = this; } /** * Setup performance tracking */ setupPerformanceTracking() { // Web Vitals tracking if ('PerformanceObserver' in window) { // Largest Contentful Paint new PerformanceObserver((list) => { const entries = list.getEntries(); const lastEntry = entries[entries.length - 1]; this.recordPerformanceMetric({ metric: 'LCP', value: lastEntry.startTime, rating: lastEntry.startTime < 2500 ? 'good' : lastEntry.startTime < 4000 ? 'needs-improvement' : 'poor' }); }).observe({ entryTypes: ['largest-contentful-paint'] }); // First Input Delay new PerformanceObserver((list) => { const entries = list.getEntries(); entries.forEach((entry) => { const fid = entry.processingStart - entry.startTime; this.recordPerformanceMetric({ metric: 'FID', value: fid, rating: fid < 100 ? 'good' : fid < 300 ? 'needs-improvement' : 'poor' }); }); }).observe({ entryTypes: ['first-input'] }); // Cumulative Layout Shift let cumulativeLayoutShift = 0; new PerformanceObserver((list) => { const entries = list.getEntries(); entries.forEach((entry) => { if (!entry.hadRecentInput) { cumulativeLayoutShift += entry.value; } }); this.recordPerformanceMetric({ metric: 'CLS', value: cumulativeLayoutShift, rating: cumulativeLayoutShift < 0.1 ? 'good' : cumulativeLayoutShift < 0.25 ? 'needs-improvement' : 'poor' }); }).observe({ entryTypes: ['layout-shift'] }); } // Navigation timing window.addEventListener('load', () => { setTimeout(() => { const navigation = performance.getEntriesByType('navigation')[0]; if (navigation) { this.recordPerformanceMetric({ metric: 'TTFB', value: navigation.responseStart - navigation.requestStart, rating: navigation.responseStart - navigation.requestStart < 600 ? 'good' : 'needs-improvement' }); } }, 0); }); } /** * Setup console tracking */ setupConsoleTracking() { const consoleMethods = ['log', 'info', 'warn', 'error', 'debug']; consoleMethods.forEach(method => { const originalMethod = console[method]; this.originalMethods.set(`console_${method}`, originalMethod); console[method] = (...args) => { this.recordEvent({ type: 'console_log', data: { level: method, args: args.map(arg => this.serializeArgument(arg)), stack: method === 'error' ? new Error().stack : undefined } }); return originalMethod.apply(console, args); }; }); } /** * Setup error tracking */ setupErrorTracking() { window.addEventListener('error', (event) => { this.recordEvent({ type: 'error', data: { message: event.message, filename: event.filename, lineno: event.lineno, colno: event.colno, stack: event.error?.stack, type: 'javascript' } }); this.metadata.errorCount++; }); window.addEventListener('unhandledrejection', (event) => { this.recordEvent({ type: 'error', data: { message: 'Unhandled Promise Rejection', reason: String(event.reason), stack: event.reason?.stack, type: 'promise' } }); this.metadata.errorCount++; }); } /** * Record user interaction */ recordUserInteraction(type, event) { const target = this.getElementSelector(event.target); const data = { type, target }; switch (type) { case 'click': const clickEvent = event; data.coordinates = { x: clickEvent.clientX, y: clickEvent.clientY }; break; case 'scroll': data.scrollPosition = { x: window.scrollX, y: window.scrollY }; break; case 'input': const inputEvent = event; const inputTarget = inputEvent.target; if (inputTarget && !this.isSensitiveInput(inputTarget)) { data.value = inputTarget.value; } break; case 'resize': data.viewport = this.getViewportSize(); break; } this.recordEvent({ type: 'user_interaction', data }); } /** * Record DOM changes */ recordDOMChange(mutation) { const target = this.getElementSelector(mutation.target); let data; switch (mutation.type) { case 'childList': if (mutation.addedNodes.length > 0) { data = { type: 'added', target, nodeName: mutation.addedNodes[0].nodeName }; } else if (mutation.removedNodes.length > 0) { data = { type: 'removed', target, nodeName: mutation.removedNodes[0].nodeName }; } else { return; } break; case 'attributes': data = { type: 'attributes', target, attributes: { [mutation.attributeName]: mutation.target.getAttribute(mutation.attributeName) } }; break; case 'characterData': data = { type: 'characterData', target, textContent: mutation.target.textContent || '' }; break; default: return; } this.recordEvent({ type: 'dom_mutation', data }); } /** * Record network request */ recordNetworkRequest(networkData) { this.recordEvent({ type: 'network_request', data: networkData }); } /** * Record performance metric */ recordPerformanceMetric(perfData) { this.recordEvent({ type: 'performance', data: perfData }); } /** * Record a session event */ recordEvent(event) { if (!this.isActive) return; const sessionEvent = { id: this.generateEventId(), timestamp: Date.now(), ...event }; this.eventBuffer.push(sessionEvent); this.metadata.eventCount++; // Auto-flush buffer if it gets too large if (this.eventBuffer.length >= this.maxBufferSize) { this.flush(); } // Record with profiler this.profiler.recordMetric('session_event', { sessionKey: this.sessionId, eventType: event.type, timestamp: sessionEvent.timestamp }); } /** * Flush buffered events to storage */ flush() { if (this.eventBuffer.length === 0) return; // Move buffer to main events array this.events.push(...this.eventBuffer); this.eventBuffer = []; // Trim events if we exceed maximum if (this.events.length > this.maxEvents) { this.events = this.events.slice(-this.maxEvents); } // Update metadata this.metadata.duration = Date.now() - this.metadata.startTime; } /** * Get current session data */ getSessionData() { this.flush(); return { metadata: { ...this.metadata }, events: [...this.events] }; } /** * Export session as JSON */ exportSession() { const sessionData = this.getSessionData(); return JSON.stringify(sessionData, null, 2); } /** * Get session statistics */ getSessionStats() { const eventTypes = this.events.reduce((acc, event) => { acc[event.type] = (acc[event.type] || 0) + 1; return acc; }, {}); const errors = this.events.filter(e => e.type === 'error'); const networkRequests = this.events.filter(e => e.type === 'network_request'); const userInteractions = this.events.filter(e => e.type === 'user_interaction'); return { sessionId: this.sessionId, duration: this.metadata.duration, totalEvents: this.events.length, eventTypes, errorCount: errors.length, networkRequestCount: networkRequests.length, userInteractionCount: userInteractions.length, averageEventRate: this.metadata.duration ? (this.events.length / (this.metadata.duration / 1000)) : 0 }; } /** * Helper methods */ generateSessionId() { return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } generateEventId() { return `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } getElementSelector(element) { if (!element) return 'unknown'; let selector = element.tagName.toLowerCase(); if (element.id) { selector += `#${element.id}`; } if (element.className) { const classes = element.className.toString().split(' ').filter(c => c.trim()); if (classes.length > 0) { selector += `.${classes.join('.')}`; } } return selector; } getViewportSize() { if (typeof window === 'undefined') { return { width: 0, height: 0 }; } return { width: window.innerWidth, height: window.innerHeight }; } isSensitiveInput(element) { const sensitiveTypes = ['password', 'email', 'tel', 'number']; const sensitiveNames = ['password', 'email', 'phone', 'credit', 'ssn', 'social']; return sensitiveTypes.includes(element.type) || sensitiveNames.some(name => element.name?.toLowerCase().includes(name) || element.id?.toLowerCase().includes(name)); } estimateRequestSize(init) { if (!init?.body) return 0; if (typeof init.body === 'string') { return init.body.length; } return JSON.stringify(init.body).length; } estimateResponseSize(response) { const contentLength = response.headers.get('content-length'); return contentLength ? parseInt(contentLength, 10) : 0; } serializeArgument(arg) { if (arg === null || arg === undefined) return arg; if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') return arg; if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack }; if (typeof arg === 'object') { try { return JSON.parse(JSON.stringify(arg)); } catch { return '[Circular or Non-serializable Object]'; } } return String(arg); } /** * Stop session recording */ async stop() { if (!this.isActive) return; this.isActive = false; // Clear flush interval if (this.flushInterval) { clearInterval(this.flushInterval); } // Restore original methods this.originalMethods.forEach((method, key) => { if (key === 'fetch') { window.fetch = method; } else if (key === 'xhr_open') { XMLHttpRequest.prototype.open = method; } else if (key === 'xhr_send') { XMLHttpRequest.prototype.send = method; } else if (key.startsWith('console_')) { const consoleMethod = key.replace('console_', ''); console[consoleMethod] = method; } }); // Disconnect observers Object.values(this.observers).forEach(observer => { if (observer && typeof observer.disconnect === 'function') { observer.disconnect(); } else if (typeof observer === 'function') { // Event listener - would need to remove manually // For simplicity, we'll just mark them as inactive } }); // Final flush this.flush(); // Update metadata this.metadata.endTime = Date.now(); this.metadata.duration = this.metadata.endTime - this.metadata.startTime; // Record session end this.profiler.recordMetric('session_end', { ...this.metadata, timestamp: Date.now() }); await this.profiler.stop(); console.log('🛑 Session Replay Agent stopped'); } // NEW METHODS FOR TESTS startRecording() { return this.init(); } async stopRecording() { await this.stop(); } clearSession() { this.events = []; this.metadata.eventCount = 0; this.metadata.errorCount = 0; } recordCustomEvent(type, data) { this.recordEvent({ type: 'custom', data: { type, ...data } }); } } exports.SessionReplayAgent = SessionReplayAgent; // Export default instance exports.sessionReplayAgent = new SessionReplayAgent(); // Auto-initialize if in browser environment if (typeof window !== 'undefined') { exports.sessionReplayAgent.init().catch(console.error); } //# sourceMappingURL=index.js.map