UNPKG

humanbehavior-js

Version:

SDK for HumanBehavior session and event recording

1,320 lines (1,131 loc) 49.4 kB
import { record } from '@rrweb/record'; import type { listenerHandler } from '@rrweb/types'; import { v1 as uuidv1 } from 'uuid'; import { HumanBehaviorAPI } from './api'; import { RedactionManager, RedactionOptions } from './redact'; import { logger, logError, logWarn, logInfo, logDebug } from './utils/logger'; // Check if we're in a browser environment const isBrowser = typeof window !== 'undefined'; // Add type declaration at the top level declare global { interface Window { HumanBehaviorTracker: typeof HumanBehaviorTracker; __humanBehaviorGlobalTracker?: HumanBehaviorTracker; } } export class HumanBehaviorTracker { private eventIngestionQueue: any[] = []; private sessionId!: string; private userProperties: Record<string, any> = {}; private isProcessing: boolean = false; private flushInterval: number | null = null; private readonly FLUSH_INTERVAL_MS = 5000; // Flush every 5 seconds private api!: HumanBehaviorAPI; private endUserId: string | null = null; private apiKey!: string; private initialized: boolean = false; public initializationPromise: Promise<void> | null = null; private redactionManager!: RedactionManager; // Console tracking properties private originalConsole: { log: typeof console.log; warn: typeof console.warn; error: typeof console.error; } | null = null; private consoleTrackingEnabled: boolean = false; // Navigation tracking properties public navigationTrackingEnabled: boolean = false; private currentUrl: string = ''; private previousUrl: string = ''; private originalPushState: typeof history.pushState | null = null; private originalReplaceState: typeof history.replaceState | null = null; private navigationListeners: Array<() => void> = []; private _connectionBlocked: boolean = false; private recordInstance: listenerHandler | null = null; private sessionStartTime: number = Date.now(); private rrwebRecord: any = null; private fullSnapshotTimeout: number | null = null; private recordCanvas: boolean = false; // Store canvas recording preference /** * Initialize the HumanBehavior tracker * This is the main entry point - call this once per page */ public static init(apiKey: string, options?: { ingestionUrl?: string; logLevel?: 'none' | 'error' | 'warn' | 'info' | 'debug'; redactFields?: string[]; enableAutomaticTracking?: boolean; suppressConsoleErrors?: boolean; // New option to control error suppression recordCanvas?: boolean; // Enable canvas recording with PostHog-style protection automaticTrackingOptions?: { trackButtons?: boolean; trackLinks?: boolean; trackForms?: boolean; includeText?: boolean; includeClasses?: boolean; }; }): HumanBehaviorTracker { // ✅ SUPPRESS COMMON RRWEB ERRORS FOR CLEAN CONSOLE if (isBrowser && options?.suppressConsoleErrors !== false) { // Suppress canvas security errors const originalConsoleError = console.error; console.error = (...args: any[]) => { const message = args.join(' '); if ( message.includes('SecurityError: Failed to execute \'toDataURL\'') || message.includes('Tainted canvases may not be exported') || message.includes('Cannot inline img src=') || message.includes('Cross-Origin') || message.includes('CORS') || message.includes('Access-Control-Allow-Origin') || message.includes('Failed to load resource') || message.includes('net::ERR_BLOCKED_BY_CLIENT') ) { // Silently suppress these common rrweb errors return; } originalConsoleError.apply(console, args); }; // Suppress console.warn for similar issues const originalConsoleWarn = console.warn; console.warn = (...args: any[]) => { const message = args.join(' '); if ( message.includes('Cannot inline img src=') || message.includes('Cross-Origin') || message.includes('CORS') || message.includes('Access-Control-Allow-Origin') || message.includes('Failed to load resource') || message.includes('net::ERR_BLOCKED_BY_CLIENT') ) { // Silently suppress these common rrweb warnings return; } originalConsoleWarn.apply(console, args); }; // Add global error handler for any remaining rrweb errors window.addEventListener('error', (event) => { const message = event.message || ''; if ( message.includes('SecurityError') || message.includes('Tainted canvases') || message.includes('toDataURL') || message.includes('Cross-Origin') || message.includes('CORS') ) { event.preventDefault(); return false; } }); } // Return existing instance if already initialized if (isBrowser && window.__humanBehaviorGlobalTracker) { logDebug('Tracker already initialized, returning existing instance'); return window.__humanBehaviorGlobalTracker; } // Configure logging if specified if (options?.logLevel) { this.configureLogging({ level: options.logLevel }); } // Create new tracker instance const tracker = new HumanBehaviorTracker(apiKey, options?.ingestionUrl); // Store canvas recording preference tracker.recordCanvas = options?.recordCanvas ?? false; // Set redacted fields if specified if (options?.redactFields) { tracker.setRedactedFields(options.redactFields); // ✅ Apply redaction classes to existing elements tracker.redactionManager.applyRedactionClasses(); } // Setup automatic tracking if enabled if (options?.enableAutomaticTracking !== false) { tracker.setupAutomaticTracking(options?.automaticTrackingOptions); } // Start tracking tracker.start(); return tracker; } constructor(apiKey: string | undefined, ingestionUrl?: string) { if (!apiKey) { throw new Error('Human Behavior API Key is required'); } // Initialize API //const defaultIngestionUrl = 'http://3.137.217.33:3000'; // AWS Development Server //const defaultIngestionUrl = 'http://ingestion-server-alb-1823866402.us-east-2.elb.amazonaws.com'; // ALB const defaultIngestionUrl = 'https://ingest.humanbehavior.co'; // HTTPS ALB this.api = new HumanBehaviorAPI({ apiKey: apiKey, ingestionUrl: ingestionUrl || defaultIngestionUrl }); this.apiKey = apiKey; this.redactionManager = new RedactionManager(); // Handle session restoration with improved continuity if (isBrowser) { const existingSessionId = localStorage.getItem(`human_behavior_session_id_${this.apiKey}`); const lastActivity = localStorage.getItem(`human_behavior_last_activity_${this.apiKey}`); const fifteenMinutesAgo = Date.now() - (15 * 60 * 1000); // Check if we have an existing session that's still within the activity window if (existingSessionId && lastActivity && parseInt(lastActivity) > fifteenMinutesAgo) { this.sessionId = existingSessionId; logDebug(`Reusing existing session: ${this.sessionId}`); // Update activity timestamp to extend the session window localStorage.setItem(`human_behavior_last_activity_${this.apiKey}`, Date.now().toString()); } else { // Clear old session data if it's expired if (existingSessionId) { logDebug(`Session expired, clearing old session: ${existingSessionId}`); localStorage.removeItem(`human_behavior_session_id_${this.apiKey}`); localStorage.removeItem(`human_behavior_last_activity_${this.apiKey}`); } this.sessionId = uuidv1(); logDebug(`Creating new session: ${this.sessionId}`); localStorage.setItem(`human_behavior_session_id_${this.apiKey}`, this.sessionId); localStorage.setItem(`human_behavior_last_activity_${this.apiKey}`, Date.now().toString()); } this.currentUrl = window.location.href; window.__humanBehaviorGlobalTracker = this; } else { this.sessionId = uuidv1(); } // Start initialization this.initializationPromise = this.init(); } private async init(): Promise<void> { try { const userId = this.getCookie(`human_behavior_end_user_id_${this.apiKey}`); logDebug(`Initializing with sessionId: ${this.sessionId}, userId: ${userId}`); const { sessionId, endUserId } = await this.api.init(this.sessionId, userId); // Check if server returned a different session ID (for session continuity) if (sessionId !== this.sessionId) { logDebug(`Server returned different sessionId: ${sessionId} (client had: ${this.sessionId})`); this.sessionId = sessionId; // Update localStorage with server's session ID for continuity if (isBrowser) { localStorage.setItem(`human_behavior_session_id_${this.apiKey}`, this.sessionId); } } this.endUserId = endUserId; this.setCookie(`human_behavior_end_user_id_${this.apiKey}`, endUserId, 365); // Only setup browser-specific handlers when in browser environment if (isBrowser) { this.setupPageUnloadHandler(); this.setupNavigationTracking(); } else { logWarn('HumanBehaviorTracker initialized in a non-browser environment. Session tracking is disabled.'); } this.initialized = true; logInfo(`HumanBehaviorTracker initialized with sessionId: ${this.sessionId}, endUserId: ${endUserId}`); } catch (error) { logError('Failed to initialize HumanBehaviorTracker:', error); throw error; } } private async ensureInitialized(): Promise<void> { if (!this.initializationPromise) { throw new Error('HumanBehaviorTracker initialization failed'); } await this.initializationPromise; } /** * Setup navigation event tracking for SPA navigation */ private setupNavigationTracking(): void { if (!isBrowser || this.navigationTrackingEnabled) return; this.navigationTrackingEnabled = true; logDebug('Setting up navigation tracking'); // Store original history methods this.originalPushState = history.pushState; this.originalReplaceState = history.replaceState; // Override pushState to capture programmatic navigation history.pushState = (...args) => { this.previousUrl = this.currentUrl; this.currentUrl = window.location.href; // Call original method this.originalPushState!.apply(history, args); // Track navigation event this.trackNavigationEvent('pushState', this.previousUrl, this.currentUrl); // Take FullSnapshot on navigation this.takeFullSnapshot(); }; // Override replaceState to capture programmatic navigation history.replaceState = (...args) => { this.previousUrl = this.currentUrl; this.currentUrl = window.location.href; // Call original method this.originalReplaceState!.apply(history, args); // Track navigation event this.trackNavigationEvent('replaceState', this.previousUrl, this.currentUrl); // Take FullSnapshot on navigation this.takeFullSnapshot(); }; // Listen for popstate events (back/forward navigation) const popstateListener = () => { this.previousUrl = this.currentUrl; this.currentUrl = window.location.href; this.trackNavigationEvent('popstate', this.previousUrl, this.currentUrl); // Take FullSnapshot on navigation this.takeFullSnapshot(); }; window.addEventListener('popstate', popstateListener); this.navigationListeners.push(() => { window.removeEventListener('popstate', popstateListener); }); // Listen for hashchange events const hashchangeListener = () => { this.previousUrl = this.currentUrl; this.currentUrl = window.location.href; this.trackNavigationEvent('hashchange', this.previousUrl, this.currentUrl); }; window.addEventListener('hashchange', hashchangeListener); this.navigationListeners.push(() => { window.removeEventListener('hashchange', hashchangeListener); }); // Track initial page load this.trackNavigationEvent('pageLoad', '', this.currentUrl); } /** * Track navigation events and send custom events */ public async trackNavigationEvent(type: string, fromUrl: string, toUrl: string): Promise<void> { if (!this.initialized) return; try { const navigationData = { type: type, from: fromUrl, to: toUrl, timestamp: new Date().toISOString(), pathname: window.location.pathname, search: window.location.search, hash: window.location.hash, referrer: document.referrer }; // Add navigation event to the main event stream await this.addEvent({ type: 5, // Custom event type data: { payload: { eventType: 'navigation', ...navigationData } }, timestamp: Date.now() }); logDebug(`Navigation tracked: ${type} from ${fromUrl} to ${toUrl}`); } catch (error) { logError('Failed to track navigation event:', error); } } public async trackPageView(url?: string): Promise<void> { if (!this.initialized) return; try { const pageViewData = { url: url || window.location.href, pathname: window.location.pathname, search: window.location.search, hash: window.location.hash, referrer: document.referrer, timestamp: new Date().toISOString() }; // Add pageview event to the main event stream await this.addEvent({ type: 5, // Custom event type data: { payload: { eventType: 'pageview', ...pageViewData } }, timestamp: Date.now() }); logDebug(`Pageview tracked: ${pageViewData.url}`); } catch (error) { logError('Failed to track pageview event:', error); } } public async customEvent(eventName: string, properties?: Record<string, any>): Promise<void> { if (!this.initialized) return; try { // Send custom event directly to the API await this.api.sendCustomEvent(this.sessionId, eventName, properties); logDebug(`Custom event tracked: ${eventName}`, properties); } catch (error: any) { logError('Failed to track custom event:', error); // Handle specific error types - check for any custom event failure if (error.message?.includes('500') || error.message?.includes('Internal Server Error') || error.message?.includes('Failed to send custom event')) { logWarn('Custom event endpoint failed, using fallback'); } else if (error.message?.includes('ERR_BLOCKED_BY_CLIENT')) { logWarn('Custom event request blocked by ad blocker, using fallback'); } else if (error.message?.includes('Failed to fetch')) { logWarn('Custom event network error, using fallback'); } // Always try fallback for any custom event error try { const customEventData = { eventName: eventName, properties: properties || {}, timestamp: new Date().toISOString(), url: window.location.href, pathname: window.location.pathname }; await this.addEvent({ type: 5, // Custom event type data: { payload: { eventType: 'custom', ...customEventData } }, timestamp: Date.now() }); logDebug(`Custom event added to event stream as fallback: ${eventName}`); } catch (fallbackError) { logError('Failed to add custom event to event stream as fallback:', fallbackError); } } } /** * Setup automatic tracking for buttons, links, and forms */ private setupAutomaticTracking(options?: { trackButtons?: boolean; trackLinks?: boolean; trackForms?: boolean; includeText?: boolean; includeClasses?: boolean; }): void { if (!isBrowser) return; const config = { trackButtons: options?.trackButtons !== false, trackLinks: options?.trackLinks !== false, trackForms: options?.trackForms !== false, includeText: options?.includeText !== false, includeClasses: options?.includeClasses || false }; logDebug('Setting up automatic tracking with config:', config); // Setup button tracking if (config.trackButtons) { this.setupAutomaticButtonTracking(config); } // Setup link tracking if (config.trackLinks) { this.setupAutomaticLinkTracking(config); } // Setup form tracking if (config.trackForms) { this.setupAutomaticFormTracking(config); } } /** * Setup automatic button tracking */ private setupAutomaticButtonTracking(config: { includeText?: boolean; includeClasses?: boolean; }): void { document.addEventListener('click', async (event) => { const target = event.target as HTMLElement; // Track button clicks if (target.tagName === 'BUTTON' || target.closest('button')) { const button = target.tagName === 'BUTTON' ? target as HTMLButtonElement : target.closest('button') as HTMLButtonElement; const properties: Record<string, any> = { buttonId: button.id || null, buttonType: button.type || 'button', page: window.location.pathname, timestamp: Date.now() }; if (config.includeText) { properties.buttonText = button.textContent?.trim() || null; } if (config.includeClasses) { properties.buttonClass = button.className || null; } // Remove null values Object.keys(properties).forEach(key => { if (properties[key] === null) { delete properties[key]; } }); await this.customEvent('button_clicked', properties); } }); } /** * Setup automatic link tracking */ private setupAutomaticLinkTracking(config: { includeText?: boolean; includeClasses?: boolean; }): void { document.addEventListener('click', async (event) => { const target = event.target as HTMLElement; // Track link clicks if (target.tagName === 'A' || target.closest('a')) { const link = target.tagName === 'A' ? target as HTMLAnchorElement : target.closest('a') as HTMLAnchorElement; const properties: Record<string, any> = { linkUrl: link.href || null, linkId: link.id || null, linkTarget: link.target || null, page: window.location.pathname, timestamp: Date.now() }; if (config.includeText) { properties.linkText = link.textContent?.trim() || null; } if (config.includeClasses) { properties.linkClass = link.className || null; } // Remove null values Object.keys(properties).forEach(key => { if (properties[key] === null) { delete properties[key]; } }); await this.customEvent('link_clicked', properties); } }); } /** * Setup automatic form tracking */ private setupAutomaticFormTracking(config: { includeText?: boolean; includeClasses?: boolean; }): void { document.addEventListener('submit', async (event) => { const form = event.target as HTMLFormElement; const formData = new FormData(form); const properties: Record<string, any> = { formId: form.id || null, formAction: form.action || null, formMethod: form.method || 'get', fields: Array.from(formData.keys()), page: window.location.pathname, timestamp: Date.now() }; if (config.includeClasses) { properties.formClass = form.className || null; } // Remove null values Object.keys(properties).forEach(key => { if (properties[key] === null) { delete properties[key]; } }); await this.customEvent('form_submitted', properties); }); } /** * Cleanup navigation tracking */ private cleanupNavigationTracking(): void { if (!this.navigationTrackingEnabled) return; // Restore original history methods if (this.originalPushState) { history.pushState = this.originalPushState; } if (this.originalReplaceState) { history.replaceState = this.originalReplaceState; } // Remove event listeners this.navigationListeners.forEach(cleanup => cleanup()); this.navigationListeners = []; this.navigationTrackingEnabled = false; logDebug('Navigation tracking cleaned up'); } public static logToStorage(message: string) { logInfo(message); } /** * Configure logging behavior for the SDK * @param config Logger configuration options */ public static configureLogging(config: { level?: 'none' | 'error' | 'warn' | 'info' | 'debug', enableConsole?: boolean, enableStorage?: boolean }) { const levelMap = { 'none': 0, 'error': 1, 'warn': 2, 'info': 3, 'debug': 4 }; logger.setConfig({ level: levelMap[config.level || 'error'], enableConsole: config.enableConsole !== false, enableStorage: config.enableStorage || false }); } /** * Enable console event tracking */ public enableConsoleTracking(): void { if (!isBrowser || this.consoleTrackingEnabled) return; // Store original console methods this.originalConsole = { log: console.log, warn: console.warn, error: console.error }; // Override console methods to capture ALL console output (including logger output) console.log = (...args) => { this.trackConsoleEvent('log', args); this.originalConsole!.log(...args); }; console.warn = (...args) => { this.trackConsoleEvent('warn', args); this.originalConsole!.warn(...args); }; console.error = (...args) => { this.trackConsoleEvent('error', args); this.originalConsole!.error(...args); }; this.consoleTrackingEnabled = true; logDebug('Console tracking enabled'); } /** * Disable console event tracking */ public disableConsoleTracking(): void { if (!isBrowser || !this.consoleTrackingEnabled) return; // Restore original console methods if (this.originalConsole) { console.log = this.originalConsole.log; console.warn = this.originalConsole.warn; console.error = this.originalConsole.error; } this.consoleTrackingEnabled = false; logDebug('Console tracking disabled'); } private trackConsoleEvent(level: 'log' | 'warn' | 'error', args: any[]): void { if (!this.initialized) return; try { const consoleData = { level: level, message: args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg) ).join(' '), timestamp: new Date().toISOString(), url: window.location.href }; // Add console event to the main event stream this.addEvent({ type: 5, // Custom event type data: { payload: { eventType: 'console', ...consoleData } }, timestamp: Date.now() }).catch(error => { logError('Failed to track console event:', error); }); } catch (error) { logError('Error in trackConsoleEvent:', error); } } private setupPageUnloadHandler() { if (!isBrowser) return; logDebug('Setting up page unload handler'); // Handle visibility changes for sending events window.addEventListener('visibilitychange', () => { // Only send events when page becomes hidden if (document.visibilityState === 'hidden') { logDebug('Page hidden - sending pending events'); this.api.sendBeaconEvents(this.eventIngestionQueue, this.sessionId); } }); // Handle actual page unload/close window.addEventListener('beforeunload', () => { // Send final events this.api.sendBeaconEvents(this.eventIngestionQueue, this.sessionId); }); // Update activity timestamp on user interaction (not on page load) const updateActivity = () => { localStorage.setItem(`human_behavior_last_activity_${this.apiKey}`, Date.now().toString()); }; // Listen for user interactions to update activity timestamp window.addEventListener('click', updateActivity); window.addEventListener('keydown', updateActivity); window.addEventListener('scroll', updateActivity); window.addEventListener('mousemove', updateActivity); } public viewLogs() { try { const logs = logger.getLogs(); logInfo('HumanBehavior Logs:', logs); logger.clearLogs(); // Clear logs after viewing } catch (e) { logError('Failed to read logs:', e); } } /** * Add user identification information to the tracker * If userId is not provided, will use userProperties.email as the userId (if present) */ public async identifyUser( { userProperties }: { userProperties: Record<string, any> } ): Promise<string> { await this.ensureInitialized(); // Keep the original endUserId (UUID) - don't change it const originalEndUserId = this.endUserId; // Store user properties this.userProperties = userProperties; logDebug('Identifying user:', { userProperties, originalEndUserId, sessionId: this.sessionId }); // Send user data with the original endUserId await this.api.sendUserData(originalEndUserId!, userProperties, this.sessionId); // Don't update endUserId - keep it as the original UUID return originalEndUserId || ''; } /** * Get current user attributes */ public getUserAttributes(): Record<string, any> { return { ...this.userProperties }; } public async start() { await this.ensureInitialized(); if (!isBrowser) return; // Start periodic flushing this.flushInterval = window.setInterval(() => { this.flush(); }, this.FLUSH_INTERVAL_MS); // Disable console tracking to reduce event pollution // this.enableConsoleTracking(); // ✅ DOM READY DETECTION // Wait for DOM to be ready before starting recording const startRecording = () => { logDebug('🎯 DOM ready, starting session recording'); // ✅ HUMANBEHAVIOR RRWEB CONFIGURATION this.rrwebRecord = record; const recordInstance = record({ emit: (event) => { // ✅ DIRECT EVENT HANDLING - Let rrweb handle events natively this.addEvent(event); // ✅ DEBUG FULLSNAPSHOT GENERATION if (event.type === 2) { // FullSnapshot logDebug(`🎯 FullSnapshot generated at ${new Date().toISOString()}`); } }, // ✅ HUMANBEHAVIOR'S CUSTOM SETTINGS maskTextSelector: this.redactionManager.getMaskTextSelector() || undefined, maskTextFn: undefined, maskAllInputs: true, // HumanBehavior default maskInputOptions: { password: true }, // HumanBehavior default maskInputFn: undefined, slimDOMOptions: {}, // ✅ ERROR SUPPRESSION SETTINGS - Disabled to prevent console noise collectFonts: false, // Disable font collection to reduce errors inlineStylesheet: true, // Keep styles for proper session replay recordCrossOriginIframes: false, // Prevent cross-origin iframe errors // ✅ CANVAS RECORDING - PostHog-style protection against overwhelm recordCanvas: this.recordCanvas, // Opt-in only sampling: this.recordCanvas ? { canvas: 4 } : undefined, // 4 FPS throttle dataURLOptions: this.recordCanvas ? { type: 'image/webp', quality: 0.4 } : undefined, // WebP with 40% quality // ✅ FULLSNAPSHOT GENERATION - No periodic snapshots to avoid animation issues // Rely on initial FullSnapshot + navigation-triggered ones only }); // Store the record instance for cleanup this.recordInstance = recordInstance || null; }; // ✅ DOM READY DETECTION logDebug(`🎯 DOM ready state: ${document.readyState}`); if (document.readyState === 'complete') { // DOM already ready, start immediately logDebug('🎯 DOM already complete, starting recording immediately'); startRecording(); } else { // Wait for DOM to be ready logDebug('🎯 DOM not ready, waiting for DOMContentLoaded event'); document.addEventListener('DOMContentLoaded', () => { logDebug('🎯 DOMContentLoaded fired, starting recording'); startRecording(); }, { once: true }); } } /** * Manually trigger a FullSnapshot (for navigation events) * Delays snapshot to avoid capturing mid-animation states */ private takeFullSnapshot(): void { // Clear any existing timeout to avoid multiple snapshots if (this.fullSnapshotTimeout) { clearTimeout(this.fullSnapshotTimeout); } // Delay FullSnapshot to let animations settle this.fullSnapshotTimeout = window.setTimeout(() => { try { // Wait for any pending animations/transitions to complete requestAnimationFrame(() => { requestAnimationFrame(() => { // Access takeFullSnapshot from the rrweb record function if (this.rrwebRecord && typeof this.rrwebRecord.takeFullSnapshot === 'function') { this.rrwebRecord.takeFullSnapshot(); logDebug('✅ FullSnapshot taken for navigation (delayed for animations)'); } else { logWarn('⚠️ takeFullSnapshot not available on record function'); } }); }); } catch (error) { logError('❌ Failed to take FullSnapshot for navigation:', error); } }, 1000); // Wait 1 second for animations to settle } public async stop() { await this.ensureInitialized(); if (!isBrowser) return; if (this.flushInterval) { clearInterval(this.flushInterval); this.flushInterval = null; } // Stop rrweb recording if (this.recordInstance) { this.recordInstance(); this.recordInstance = null; } // Clear any pending FullSnapshot timeouts if (this.fullSnapshotTimeout) { clearTimeout(this.fullSnapshotTimeout); this.fullSnapshotTimeout = null; } this.rrwebRecord = null; // Disable console tracking this.disableConsoleTracking(); // Cleanup navigation tracking this.cleanupNavigationTracking(); } /** * Add an event to the ingestion queue * Events are sent directly without processing to avoid corruption */ public async addEvent(event: any) { await this.ensureInitialized(); // ✅ DIRECT EVENT HANDLING - No custom processing to avoid corruption // Events flow directly from rrweb to ingestion server // ✅ EVENT VALIDATION if (!event || typeof event !== 'object') { logDebug('⚠️ Skipping invalid event:', event); return; } // ✅ LOG FULLSNAPSHOT STATUS FOR DEBUGGING if (event.type === 2) { // FullSnapshot const hasData = !!event.data; const hasNode = !!(event.data && event.data.node); if (!hasData || !hasNode) { logDebug(`⚠️ Empty FullSnapshot detected: hasData=${hasData}, hasNode=${hasNode} - continuing session`); } else { logDebug(`✅ Valid FullSnapshot: hasData=${hasData}, hasNode=${hasNode}, dataType=${event.data?.node?.type}`); } } this.eventIngestionQueue.push(event); // Direct event handling } /** * Flush events to the ingestion server * Events are sent in chunks to handle large payloads efficiently */ private async flush() { // Prevent concurrent flushes if (this.isProcessing || !this.initialized) { return; } this.isProcessing = true; try { // Swap the current queue with an empty one atomically const eventsToProcess = this.eventIngestionQueue; this.eventIngestionQueue = []; if (eventsToProcess.length > 0) { logDebug('Flushing events:', eventsToProcess); // ✅ LOG FULLSNAPSHOT STATUS FOR MONITORING const fullSnapshots = eventsToProcess.filter(e => e.type === 2); if (fullSnapshots.length > 0) { logDebug(`[FIXED] Sending ${fullSnapshots.length} FullSnapshot(s) with valid data`); } try { // Use chunked sending to handle large payloads await this.api.sendEventsChunked(eventsToProcess, this.sessionId, this.endUserId!); } catch (error: any) { // Handle specific error types with graceful degradation if (error.message?.includes('ERROR: Session already completed')) { logWarn('Session expired, events will be lost'); } else if (error.message?.includes('413') || error.message?.includes('Content Too Large')) { logWarn('Payload too large, events will be lost'); } else if (error.message?.includes('ERR_BLOCKED_BY_CLIENT') || error.message?.includes('Failed to fetch') || error.message?.includes('NetworkError')) { logWarn('Request blocked by ad blocker or network issue, events will be lost'); } else { throw error; } } } } finally { this.isProcessing = false; } } // Add helper methods for cookie management with localStorage fallback private setCookie(name: string, value: string, daysToExpire: number) { if (!isBrowser) return; try { // Try to set cookie first const date = new Date(); date.setTime(date.getTime() + (daysToExpire * 24 * 60 * 60 * 1000)); const expires = `expires=${date.toUTCString()}`; document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`; // Also store in localStorage as backup localStorage.setItem(name, value); logDebug(`Set cookie and localStorage: ${name}`); } catch (error) { // If cookie fails, use localStorage only try { localStorage.setItem(name, value); logDebug(`Cookie blocked, using localStorage: ${name}`); } catch (localStorageError) { logError('Failed to store user ID in both cookie and localStorage:', localStorageError); } } } public getCookie(name: string): string | null { if (!isBrowser) return null; try { // Try to get from cookie first const nameEQ = name + "="; const ca = document.cookie.split(';'); for (let i = 0; i < ca.length; i++) { let c = ca[i]; while (c.charAt(0) === ' ') c = c.substring(1, c.length); if (c.indexOf(nameEQ) === 0) { const cookieValue = c.substring(nameEQ.length, c.length); logDebug(`Found cookie: ${name}`); return cookieValue; } } // If cookie not found, try localStorage const localStorageValue = localStorage.getItem(name); if (localStorageValue) { logDebug(`Cookie not found, using localStorage: ${name}`); return localStorageValue; } return null; } catch (error) { // If cookie access fails, try localStorage try { const localStorageValue = localStorage.getItem(name); if (localStorageValue) { logDebug(`Cookie access failed, using localStorage: ${name}`); return localStorageValue; } } catch (localStorageError) { logError('Failed to access both cookie and localStorage:', localStorageError); } return null; } } /** * Delete a cookie by setting its expiration date to the past * @param name The name of the cookie to delete */ private deleteCookie(name: string) { if (!isBrowser) return; try { // Delete cookie by setting expiration to past document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax`; logDebug(`Deleted cookie: ${name}`); } catch (error) { logError(`Failed to delete cookie: ${name}`, error); } // Also remove from localStorage try { localStorage.removeItem(name); logDebug(`Removed from localStorage: ${name}`); } catch (error) { logError(`Failed to remove from localStorage: ${name}`, error); } } /** * Clear user data and reset session when user signs out of the site * This should be called when a user logs out of your application to prevent * data contamination between different users */ public logout(): void { if (!isBrowser) return; try { // Clear user ID cookie and localStorage const userIdCookieName = `human_behavior_end_user_id_${this.apiKey}`; this.deleteCookie(userIdCookieName); // Clear session data from localStorage localStorage.removeItem(`human_behavior_session_id_${this.apiKey}`); localStorage.removeItem(`human_behavior_last_activity_${this.apiKey}`); // Reset user-related properties this.endUserId = null; this.userProperties = {}; // Generate a new session ID for the next user this.sessionId = uuidv1(); if (isBrowser) { localStorage.setItem(`human_behavior_session_id_${this.apiKey}`, this.sessionId); localStorage.setItem(`human_behavior_last_activity_${this.apiKey}`, Date.now().toString()); } logInfo('User logged out - cleared all user data and started fresh session'); } catch (error) { logError('Error during logout:', error); } } /** * Start redaction functionality for sensitive input fields * @param options Optional configuration for redaction behavior */ public async redact(options?: RedactionOptions): Promise<void> { await this.ensureInitialized(); if (!isBrowser) { logWarn('Redaction is only available in browser environments'); return; } // Create a new redaction manager with the provided options this.redactionManager = new RedactionManager(options); } /** * Set specific fields to be redacted during session recording * Uses rrweb's built-in masking instead of custom redaction processing * @param fields Array of CSS selectors for fields to redact (e.g., ['input[type="password"]', '#email-field']) */ public setRedactedFields(fields: string[]): void { this.redactionManager.setFieldsToRedact(fields); // ✅ APPLY RRWEB MASKING CLASSES - More reliable than custom processing this.redactionManager.applyRedactionClasses(); // ✅ RESTART RECORDING WITH NEW SETTINGS - Ensures masking is applied if (this.recordInstance) { this.restartWithNewRedaction(); } } private restartWithNewRedaction(): void { if (this.recordInstance) { this.recordInstance(); // Stop current recording this.start(); // Restart with new redaction settings } } /** * Check if redaction is currently active */ public isRedactionActive(): boolean { return this.redactionManager.isActive(); } /** * Get the currently selected fields for redaction */ public getRedactedFields(): string[] { return this.redactionManager.getSelectedFields(); } /** * Get the current session ID */ public getSessionId(): string { return this.sessionId; } /** * Get the current URL being tracked */ public getCurrentUrl(): string { return this.currentUrl; } /** * Get current snapshot frequency info * Uses configured values (5 minutes, 1000 events) */ public getSnapshotFrequencyInfo(): { sessionDuration: number; currentInterval: number; currentThreshold: number; phase: string; } { const sessionDuration = Date.now() - this.sessionStartTime; return { sessionDuration, currentInterval: 300000, // Configured - 5 minutes currentThreshold: 1000, // Configured - 1000 events phase: 'configured' // Using explicit configuration }; } /** * Test if the tracker can reach the ingestion server */ public async testConnection(): Promise<{ success: boolean; error?: string }> { try { await this.api.init(this.sessionId, this.endUserId); return { success: true }; } catch (error: any) { return { success: false, error: error.message || 'Unknown error' }; } } /** * Get connection status and recommendations */ public getConnectionStatus(): { blocked: boolean; recommendations: string[] } { const recommendations: string[] = []; let blocked = false; // Check if we have queued events (might indicate blocking) if (this.eventIngestionQueue.length > 0) { blocked = true; recommendations.push('Some requests may be blocked by ad blockers'); } // Check if connection was blocked during initialization if (this._connectionBlocked) { blocked = true; recommendations.push('Initial connection test failed - ad blocker may be active'); } // Check if we're in a browser environment if (typeof window === 'undefined') { recommendations.push('Not running in browser environment'); } // Check if navigator.sendBeacon is available if (typeof navigator.sendBeacon === 'undefined') { recommendations.push('sendBeacon not available, using fetch fallback'); } return { blocked, recommendations }; } /** * Check if the current user is a preexisting user * Returns true if the user has an existing endUserId cookie from a previous session */ public isPreexistingUser(): boolean { if (!isBrowser) { return false; } // Check if there's an existing endUserId cookie for this API key const existingEndUserId = this.getCookie(`human_behavior_end_user_id_${this.apiKey}`); return existingEndUserId !== null && existingEndUserId !== this.endUserId; } /** * Get user information including whether they are preexisting */ public getUserInfo(): { endUserId: string | null; sessionId: string; isPreexistingUser: boolean; initialized: boolean; } { return { endUserId: this.endUserId, sessionId: this.sessionId, isPreexistingUser: this.isPreexistingUser(), initialized: this.initialized }; } } // Only expose to window object in browser environments if (isBrowser) { window.HumanBehaviorTracker = HumanBehaviorTracker; } export default HumanBehaviorTracker;