UNPKG

@randyd45/web-behavior-tracker

Version:

A framework-agnostic package for tracking user behavior on web forms

487 lines 19.6 kB
import { BrowserMetadataCollector } from './BrowserMetadataCollector.js'; import { InputEventHandler } from './InputEventHandler.js'; import { FormEventHandler } from './FormEventHandler.js'; import { MouseEventHandler } from './MouseEventHandler.js'; import { ClipboardEventHandler } from './ClipboardEventHandler.js'; import { CustomEventHandler } from './CustomEventHandler.js'; export class BehaviorTracker { constructor(options = {}) { this.events = []; this.startTime = 0; this.formFields = new Map(); this.isTracking = false; this.debounceTimers = new Map(); this.THROTTLE_DELAY = 100; // 100ms throttle delay // Ensure options are properly initialized with defaults this.options = { trackMouseMovements: false, trackFocusBlur: true, trackInputChanges: true, trackClicks: true, trackCopyPaste: true, riskThreshold: 0.7, minTimeSpent: 5000, maxTimeSpent: 300000, ...options }; this.THROTTLE_DELAY = options.throttleDelay || 100; // Initialize event handlers this.inputHandler = new InputEventHandler(this.options); this.formHandler = new FormEventHandler(this.options); this.mouseHandler = new MouseEventHandler(this.options, this.THROTTLE_DELAY); this.clipboardHandler = new ClipboardEventHandler(this.options); this.customHandler = new CustomEventHandler(this.options); this.sessionId = this.getOrCreateSessionId(); this.loadSessionData(); } getOrCreateSessionId() { const existingSession = sessionStorage.getItem(BehaviorTracker.STORAGE_KEY); if (existingSession) { const { sessionId } = JSON.parse(existingSession); return sessionId; } const newSessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; this.saveSessionData({ sessionId: newSessionId, events: [] }); return newSessionId; } saveSessionData(data) { sessionStorage.setItem(BehaviorTracker.STORAGE_KEY, JSON.stringify(data)); } loadSessionData() { const existingSession = sessionStorage.getItem(BehaviorTracker.STORAGE_KEY); if (existingSession) { const { events } = JSON.parse(existingSession); this.events = events; } } startTracking(reset = false) { if (this.isTracking) return; if (reset) { this.clearSession(); } this.isTracking = true; this.startTime = Date.now(); this.setupEventListeners(); this.setupPageUnloadHandler(); } stopTracking() { if (!this.isTracking) return; this.isTracking = false; this.removeEventListeners(); this.removePageUnloadHandler(); this.saveSessionData({ sessionId: this.sessionId, events: this.events }); // Clear all debounce timers this.debounceTimers.forEach(timerId => { window.clearTimeout(timerId); }); this.debounceTimers.clear(); // Clear input handler data this.inputHandler.clear(); } setupPageUnloadHandler() { window.addEventListener('beforeunload', this.handlePageUnload.bind(this)); } removePageUnloadHandler() { window.removeEventListener('beforeunload', this.handlePageUnload.bind(this)); } handlePageUnload() { this.saveSessionData({ sessionId: this.sessionId, events: this.events }); } getMetrics() { const currentTime = Date.now(); const timeSpent = currentTime - this.startTime; const metrics = { timeSpent, fieldInteractions: 0, fieldChanges: 0, focusCount: 0, blurCount: 0, mouseInteractions: 0, copyCount: 0, pasteCount: 0, cutCount: 0, deleteCount: 0, customEventCount: 0 }; this.events.forEach(event => { switch (event.type) { case 'focus': metrics.focusCount++; metrics.fieldInteractions++; break; case 'blur': metrics.blurCount++; metrics.fieldInteractions++; break; case 'input': case 'change': metrics.fieldChanges++; metrics.fieldInteractions++; break; case 'delete': metrics.deleteCount++; metrics.fieldChanges++; metrics.fieldInteractions++; break; case 'mouseover': case 'mouseout': metrics.mouseInteractions++; break; case 'copy': metrics.copyCount++; break; case 'paste': metrics.pasteCount++; break; case 'cut': metrics.cutCount++; break; case 'custom': metrics.customEventCount++; break; default: // Check if it's a custom event by looking for customEventName if (event.customEventName) { metrics.customEventCount++; } break; } }); return metrics; } getInsights() { const metrics = this.getMetrics(); const fieldInteractionOrder = this.getFieldInteractionOrder(); const suspiciousPatterns = this.detectSuspiciousPatterns(); const riskScore = this.calculateRiskScore(metrics, suspiciousPatterns); const browserMetadata = BrowserMetadataCollector.getBrowserMetadata(); const customEventStats = this.getCustomEventStats(); return { riskScore, suspiciousPatterns, completionRate: this.calculateCompletionRate(), averageTimePerField: metrics.timeSpent / (metrics.fieldInteractions || 1), fieldInteractionOrder, browserMetadata, customEventStats }; } getEvents() { return [...this.events]; } /** * Helper method to handle event creation and storage */ onEventCreated(event) { this.events.push(event); this.saveSessionData({ sessionId: this.sessionId, events: this.events }); } /** * Gets browser metadata using the dedicated BrowserMetadataCollector */ getBrowserMetadata() { return BrowserMetadataCollector.getBrowserMetadata(); } /** * Gets additional metadata using the dedicated BrowserMetadataCollector */ getAdditionalMetadata() { return BrowserMetadataCollector.getAdditionalMetadata(); } /** * Gets high-entropy metadata using the dedicated BrowserMetadataCollector */ async getHighEntropyMetadata() { return BrowserMetadataCollector.getHighEntropyMetadata(); } /** * Gets browser fingerprint using the dedicated BrowserMetadataCollector */ getBrowserFingerprint() { return BrowserMetadataCollector.getBrowserFingerprint(); } /** * Gets browser capabilities using the dedicated BrowserMetadataCollector */ getBrowserCapabilities() { return BrowserMetadataCollector.getBrowserCapabilities(); } /** * Creates and logs a custom event */ trackCustomEvent(eventName, customData = {}, target) { return this.customHandler.createCustomEvent(eventName, customData, target, this.onEventCreated.bind(this)); } /** * Gets all custom events */ getCustomEvents() { return this.customHandler.getCustomEvents(); } /** * Gets custom events by name */ getCustomEventsByName(eventName) { return this.customHandler.getCustomEventsByName(eventName); } /** * Gets custom events count */ getCustomEventsCount() { return this.customHandler.getCustomEventsCount(); } /** * Gets custom events count by name */ getCustomEventsCountByName(eventName) { return this.customHandler.getCustomEventsCountByName(eventName); } /** * Gets custom events statistics */ getCustomEventStats() { const customEvents = this.customHandler.getCustomEvents(); const stats = this.customHandler.getCustomEventsStats(); const recentEvents = this.customHandler.getRecentCustomEvents(10); const lastEvent = this.customHandler.getLastCustomEvent(); return { totalCustomEvents: customEvents.length, eventsByName: stats, recentEvents, lastEvent: lastEvent || undefined }; } /** * Clears all custom events */ clearCustomEvents() { this.customHandler.clearCustomEvents(); } /** * Checks if a custom event has been triggered */ hasCustomEvent(eventName) { return this.customHandler.hasCustomEvent(eventName); } /** * Gets the last occurrence of a custom event */ getLastCustomEvent(eventName) { return this.customHandler.getLastCustomEvent(eventName); } setupEventListeners() { // Remove any existing listeners first this.removeEventListeners(); // Input events if (this.options.trackInputChanges) { document.addEventListener('input', (event) => { this.inputHandler.handleInputEvent(event, this.onEventCreated.bind(this)); }, true); } // Focus and blur events if (this.options.trackFocusBlur) { document.addEventListener('focus', (event) => { this.mouseHandler.handleFocusBlurEvent(event, this.onEventCreated.bind(this)); }, true); document.addEventListener('blur', (event) => { this.mouseHandler.handleFocusBlurEvent(event, this.onEventCreated.bind(this)); }, true); } // Click events if (this.options.trackClicks) { document.addEventListener('click', (event) => { this.mouseHandler.handleClickEvent(event, this.onEventCreated.bind(this)); }, true); } // Mouse movement events if (this.options.trackMouseMovements) { document.addEventListener('mouseover', (event) => { this.mouseHandler.handleMouseMovementEvent(event, this.onEventCreated.bind(this)); }, true); document.addEventListener('mouseout', (event) => { this.mouseHandler.handleMouseMovementEvent(event, this.onEventCreated.bind(this)); }, true); } // Form-specific events document.addEventListener('change', (event) => { const target = event.target; if (target.tagName.toLowerCase() === 'select') { this.formHandler.handleSelectChange(event, this.onEventCreated.bind(this)); } else if (target instanceof HTMLInputElement && (target.type === 'checkbox' || target.type === 'radio')) { this.formHandler.handleCheckboxRadioChange(event, this.onEventCreated.bind(this)); } }, true); // Form submission and validation document.addEventListener('submit', (event) => { const target = event.target; if (target.tagName.toLowerCase() === 'form') { this.formHandler.handleFormSubmit(event, this.onEventCreated.bind(this)); } }, true); document.addEventListener('invalid', (event) => { this.formHandler.handleFormValidation(event, this.onEventCreated.bind(this)); }, true); document.addEventListener('reset', (event) => { this.formHandler.handleFormReset(event, this.onEventCreated.bind(this)); }, true); // Clipboard events if (this.options.trackCopyPaste) { document.addEventListener('copy', (event) => { this.clipboardHandler.handleCopyEvent(event, this.onEventCreated.bind(this)); }, true); document.addEventListener('paste', (event) => { this.clipboardHandler.handlePasteEvent(event, this.onEventCreated.bind(this)); }, true); document.addEventListener('cut', (event) => { this.clipboardHandler.handleCutEvent(event, this.onEventCreated.bind(this)); }, true); } } removeEventListeners() { // Since we're using arrow functions in addEventListener, we need to remove all listeners // by cloning the node or using a different approach. For now, we'll rely on the // setupEventListeners method to remove existing listeners first. // In a production environment, you might want to store references to the bound functions // to properly remove them, or use a more sophisticated event management system. // For this implementation, we'll rely on the fact that setupEventListeners // calls removeEventListeners first, and we'll recreate all listeners. } getFieldInteractionOrder() { const uniqueFields = new Set(); return this.events .filter(event => ['focus', 'input', 'delete', 'change'].includes(event.type)) .map(event => event.elementId) .filter(id => { if (uniqueFields.has(id)) return false; uniqueFields.add(id); return true; }); } detectSuspiciousPatterns() { const patterns = []; const metrics = this.getMetrics(); // Detect rapid form filling if (metrics.timeSpent < 5000 && metrics.fieldChanges > 5) { patterns.push('Rapid form filling detected'); } // Detect unusual focus patterns if (metrics.focusCount > 20 && metrics.timeSpent < 10000) { patterns.push('Unusual focus pattern detected'); } // Detect copy-paste behavior using explicit events const copyPasteEvents = this.events.filter(e => ['copy', 'paste', 'cut'].includes(e.type)); if (copyPasteEvents.length > 0) { patterns.push(`${copyPasteEvents.length} copy-paste operations detected`); // Detect specific copy-paste patterns const copyCount = copyPasteEvents.filter(e => e.type === 'copy').length; const pasteCount = copyPasteEvents.filter(e => e.type === 'paste').length; if (copyCount > 0 && pasteCount === 0) { patterns.push('Copy operations without paste detected (potential data harvesting)'); } if (copyPasteEvents.length > 10) { patterns.push('Excessive copy-paste operations detected'); } if (this.detectRapidCopyPasteSequence()) { patterns.push('Rapid copy-paste sequence detected (potential automation)'); } } // Detect rapid input events as fallback for copy-paste detection const inputEvents = this.events.filter(e => e.type === 'input'); const rapidInputs = inputEvents.filter((event, index) => { if (index === 0) return false; return event.timestamp - inputEvents[index - 1].timestamp < 50; }); if (rapidInputs.length > 3 && copyPasteEvents.length === 0) { patterns.push('Possible copy-paste behavior detected (rapid input)'); } return patterns; } calculateRiskScore(metrics, suspiciousPatterns) { let score = 0; // Time-based risk if (metrics.timeSpent < this.options.minTimeSpent) score += 0.3; if (metrics.timeSpent > this.options.maxTimeSpent) score += 0.2; // Interaction-based risk if (metrics.fieldChanges / metrics.fieldInteractions > 0.8) score += 0.2; if (metrics.mouseInteractions < 5) score += 0.1; // Copy-paste behavior risk (fraud detection patterns) const totalCopyPasteOps = metrics.copyCount + metrics.pasteCount + metrics.cutCount; const totalFieldInteractions = metrics.fieldInteractions || 1; // High copy-paste ratio indicates potential automation or data harvesting const copyPasteRatio = totalCopyPasteOps / totalFieldInteractions; if (copyPasteRatio > 0.5) score += 0.25; // More than 50% of interactions are copy-paste else if (copyPasteRatio > 0.3) score += 0.15; // 30-50% copy-paste ratio else if (copyPasteRatio > 0.1) score += 0.05; // 10-30% copy-paste ratio (normal usage) // Excessive copy-paste operations (potential data scraping) if (totalCopyPasteOps > 10) score += 0.2; // More than 10 copy-paste operations else if (totalCopyPasteOps > 5) score += 0.1; // 5-10 copy-paste operations // Copy without paste pattern (potential data harvesting) if (metrics.copyCount > 0 && metrics.pasteCount === 0) score += 0.15; // Rapid copy-paste sequence (potential automation) const rapidCopyPaste = this.detectRapidCopyPasteSequence(); if (rapidCopyPaste) score += 0.2; // Pattern-based risk score += suspiciousPatterns.length * 0.1; return Math.min(score, 1); } detectRapidCopyPasteSequence() { const copyPasteEvents = this.events.filter(e => ['copy', 'paste', 'cut'].includes(e.type)); // Check for rapid copy-paste sequences (multiple operations within 2 seconds) for (let i = 1; i < copyPasteEvents.length; i++) { const timeDiff = copyPasteEvents[i].timestamp - copyPasteEvents[i - 1].timestamp; if (timeDiff < 2000) { // Less than 2 seconds between operations return true; } } // Check for copy-paste burst (3+ operations within 5 seconds) let burstCount = 0; const burstWindow = 5000; // 5 seconds const now = Date.now(); for (const event of copyPasteEvents) { if (now - event.timestamp <= burstWindow) { burstCount++; if (burstCount >= 3) { return true; } } } return false; } calculateCompletionRate() { const requiredFields = Array.from(this.formFields.values()).filter(field => field.isRequired); if (requiredFields.length === 0) return 1; const completedFields = requiredFields.filter(field => { const fieldEvents = this.events.filter(e => e.elementId === field.id); return fieldEvents.some(e => e.type === 'change' || e.type === 'input' || e.type === 'delete'); }); return completedFields.length / requiredFields.length; } getSessionId() { return this.sessionId; } clearSession() { this.events = []; this.startTime = Date.now(); sessionStorage.removeItem(BehaviorTracker.STORAGE_KEY); this.sessionId = this.getOrCreateSessionId(); } } BehaviorTracker.STORAGE_KEY = 'web_behavior_tracker_session'; //# sourceMappingURL=BehaviorTracker.js.map