humanbehavior-js
Version:
SDK for HumanBehavior session and event recording
1,320 lines (1,131 loc) • 49.4 kB
text/typescript
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;