@randyd45/web-behavior-tracker
Version:
A framework-agnostic package for tracking user behavior on web forms
573 lines • 20.8 kB
JavaScript
export class BehaviorTracker {
constructor(options = {}) {
this.events = [];
this.startTime = 0;
this.formFields = new Map();
this.isTracking = false;
this.debounceTimers = new Map();
this.lastInputValues = new Map();
this.lastThrottledEvent = 0;
// Ensure options are properly initialized with defaults
this.options = {
trackMouseMovements: false,
trackFocusBlur: true,
trackInputChanges: true,
trackClicks: true,
customEvents: [],
riskThreshold: 0.7,
minTimeSpent: 5000,
maxTimeSpent: 300000,
...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() {
if (this.isTracking)
return;
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();
this.lastInputValues.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
};
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 'mouseover':
case 'mouseout':
metrics.mouseInteractions++;
break;
}
});
return metrics;
}
getInsights() {
const metrics = this.getMetrics();
const fieldInteractionOrder = this.getFieldInteractionOrder();
const suspiciousPatterns = this.detectSuspiciousPatterns();
const riskScore = this.calculateRiskScore(metrics, suspiciousPatterns);
return {
riskScore,
suspiciousPatterns,
completionRate: this.calculateCompletionRate(),
averageTimePerField: metrics.timeSpent / (metrics.fieldInteractions || 1),
fieldInteractionOrder
};
}
getEvents() {
return [...this.events];
}
setupEventListeners() {
// Remove any existing listeners first
this.removeEventListeners();
// Only track meaningful form events
const formEvents = [];
// Add events based on options
if (this.options.trackFocusBlur) {
formEvents.push('focus', 'blur');
}
if (this.options.trackInputChanges) {
formEvents.push('change', 'onchange', 'oninput');
}
if (this.options.trackClicks) {
formEvents.push('click');
}
if (this.options.trackMouseMovements) {
formEvents.push('mouseover', 'mouseout');
}
// Always track these events as they're essential for form functionality
formEvents.push('invalid', 'reset', 'submit');
// Track events on all form elements
const formElements = [
'input',
'select',
'textarea',
'button',
'fieldset',
'form',
'label',
'optgroup',
'option'
];
// Add event listeners for each form element type
formElements.forEach(_ => {
formEvents.forEach(eventType => {
document.addEventListener(eventType, this.handleEvent.bind(this), true);
});
});
// Special handling for input events with debouncing
if (this.options.trackInputChanges) {
document.addEventListener('input', this.handleInputEvent.bind(this), true);
}
// Special handling for select elements
document.addEventListener('change', (event) => {
const target = event.target;
if (target.tagName.toLowerCase() === 'select') {
this.handleSelectChange(event);
}
}, true);
// Special handling for checkboxes and radio buttons
document.addEventListener('change', (event) => {
const target = event.target;
if (target.type === 'checkbox' || target.type === 'radio') {
this.handleCheckboxRadioChange(event);
}
}, true);
// Track form submissions
document.addEventListener('submit', (event) => {
const target = event.target;
if (target.tagName.toLowerCase() === 'form') {
this.handleFormSubmit(event);
}
}, true);
}
removeEventListeners() {
const events = [
'focus',
'blur',
'change',
'click',
'mouseover',
'mouseout',
'input',
'invalid',
'reset',
'submit'
];
events.forEach(eventType => {
document.removeEventListener(eventType, this.handleEvent.bind(this), true);
});
document.removeEventListener('input', this.handleInputEvent.bind(this), true);
}
handleInputEvent(event) {
if (!this.isTracking || !this.options.trackInputChanges)
return;
const target = event.target;
if (!target)
return;
// Get the current value
let elementValue = null;
if (target instanceof HTMLInputElement) {
if (target.type === 'checkbox') {
elementValue = target.checked;
}
else if (target.type === 'radio') {
elementValue = target.checked ? target.value : null;
}
else {
elementValue = target.value;
}
}
else if (target instanceof HTMLSelectElement) {
if (target.multiple) {
elementValue = Array.from(target.selectedOptions).map(option => option.value);
}
else {
elementValue = target.value;
}
}
else if (target instanceof HTMLTextAreaElement) {
elementValue = target.value;
}
// For custom elements, try to get value from data attributes or aria attributes
if (elementValue === null) {
elementValue = target.getAttribute('data-value') ||
target.getAttribute('aria-valuetext') ||
target.getAttribute('value') ||
target.textContent ||
'';
}
const elementState = this.getElementState(target);
const behaviorEvent = {
type: 'input',
elementId: target.id || '',
elementType: target.tagName.toLowerCase(),
timestamp: Date.now(),
value: elementValue,
pageUrl: window.location.pathname,
elementAttributes: this.getElementAttributes(target),
elementState
};
this.events.push(behaviorEvent);
this.saveSessionData({ sessionId: this.sessionId, events: this.events });
}
handleEvent(event) {
if (!this.isTracking)
return;
// Ignore keyboard events
if (event instanceof KeyboardEvent) {
return;
}
const target = event.target;
if (!target)
return;
// Check if the element is a form element or has a form-related role
const isFormElement = this.isFormElement(target);
if (!isFormElement)
return;
// Check if we should track this event type based on options
if (event.type === 'mouseover' || event.type === 'mouseout') {
if (!this.options.trackMouseMovements) {
return;
}
}
if (event.type === 'click' && !this.options.trackClicks) {
return;
}
if ((event.type === 'focus' || event.type === 'blur') && !this.options.trackFocusBlur) {
return;
}
// Throttle events
const now = Date.now();
if (now - this.lastThrottledEvent < BehaviorTracker.THROTTLE_DELAY) {
return;
}
this.lastThrottledEvent = now;
const elementValue = this.getElementValue(target);
const elementState = this.getElementState(target);
const behaviorEvent = {
type: event.type,
elementId: target.id || '',
elementType: target.tagName.toLowerCase(),
timestamp: now,
value: elementValue,
pageUrl: window.location.pathname,
elementAttributes: this.getElementAttributes(target),
elementState
};
this.events.push(behaviorEvent);
this.saveSessionData({ sessionId: this.sessionId, events: this.events });
}
isFormElement(element) {
const formElements = [
'input',
'select',
'textarea',
'button',
'fieldset',
'form',
'label',
'optgroup',
'option'
];
// Check if element is a form element
if (formElements.includes(element.tagName.toLowerCase())) {
return true;
}
// Check if element has a form-related role
const role = element.getAttribute('role');
const formRoles = [
'textbox',
'checkbox',
'radio',
'combobox',
'listbox',
'button',
'slider',
'spinbutton'
];
return role ? formRoles.includes(role) : false;
}
getElementValue(element) {
var _a, _b;
if (element instanceof HTMLInputElement) {
if (element.type === 'checkbox') {
return element.checked.toString();
}
if (element.type === 'radio') {
return element.checked ? element.value : '';
}
return element.value;
}
if (element instanceof HTMLSelectElement) {
if (element.multiple) {
return Array.from(element.selectedOptions).map(opt => opt.value).join(',');
}
return element.value;
}
if (element instanceof HTMLTextAreaElement) {
return element.value;
}
// For custom elements, try multiple approaches to get the value
const customValue = element.getAttribute('data-value') ||
element.getAttribute('aria-valuetext') ||
element.getAttribute('value') ||
((_a = element.textContent) === null || _a === void 0 ? void 0 : _a.trim()) ||
'';
// If the element has a role of combobox or listbox, try to get the selected option
if (element.getAttribute('role') === 'combobox' || element.getAttribute('role') === 'listbox') {
const selectedOption = element.querySelector('[aria-selected="true"]');
if (selectedOption) {
return ((_b = selectedOption.textContent) === null || _b === void 0 ? void 0 : _b.trim()) || customValue;
}
}
return customValue;
}
getElementAttributes(element) {
const attributes = {};
const relevantAttributes = [
'type',
'name',
'required',
'disabled',
'readonly',
'placeholder',
'min',
'max',
'step',
'pattern',
'autocomplete',
'role',
'aria-label',
'aria-required'
];
relevantAttributes.forEach(attr => {
const value = element.getAttribute(attr);
if (value !== null) {
attributes[attr] = value;
}
});
return attributes;
}
handleSelectChange(event) {
const target = event.target;
if (!target)
return;
const selectedOptions = Array.from(target.options).map(option => ({
value: option.value,
text: option.text,
selected: option.selected
}));
const behaviorEvent = {
type: 'select-change',
elementId: target.id || '',
elementType: 'select',
timestamp: Date.now(),
value: target.value,
pageUrl: window.location.pathname,
elementAttributes: this.getElementAttributes(target),
elementState: this.getElementState(target),
selectedOptions
};
this.events.push(behaviorEvent);
this.saveSessionData({ sessionId: this.sessionId, events: this.events });
}
handleCheckboxRadioChange(event) {
const target = event.target;
const behaviorEvent = {
type: 'checkbox-radio-change',
elementId: target.id || '',
elementType: target.type,
timestamp: Date.now(),
value: target.checked.toString(),
pageUrl: window.location.pathname,
elementAttributes: this.getElementAttributes(target)
};
this.events.push(behaviorEvent);
this.saveSessionData({ sessionId: this.sessionId, events: this.events });
}
handleFormSubmit(event) {
const target = event.target;
const formData = new FormData(target);
const formValues = {};
formData.forEach((value, key) => {
formValues[key] = value.toString();
});
const behaviorEvent = {
type: 'form-submit',
elementId: target.id || '',
elementType: 'form',
timestamp: Date.now(),
value: JSON.stringify(formValues),
pageUrl: window.location.pathname,
elementAttributes: this.getElementAttributes(target)
};
this.events.push(behaviorEvent);
this.saveSessionData({ sessionId: this.sessionId, events: this.events });
}
getElementPath(element) {
const path = [];
let current = element;
while (current && current !== document.body) {
let selector = current.tagName.toLowerCase();
if (current.id) {
selector += `#${current.id}`;
}
else if (current.className) {
selector += `.${current.className.split(' ').join('.')}`;
}
path.unshift(selector);
current = current.parentElement;
}
return path.join(' > ');
}
getFieldInteractionOrder() {
const uniqueFields = new Set();
return this.events
.filter(event => ['focus', 'input', '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
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) {
patterns.push('Possible copy-paste behavior detected');
}
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;
// Pattern-based risk
score += suspiciousPatterns.length * 0.1;
return Math.min(score, 1);
}
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');
});
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();
}
getElementState(element) {
const state = {};
if (element instanceof HTMLInputElement) {
state.checked = element.checked;
state.disabled = element.disabled;
state.readOnly = element.readOnly;
state.required = element.required;
state.valid = element.validity.valid;
state.validationMessage = element.validationMessage;
}
if (element instanceof HTMLSelectElement) {
state.disabled = element.disabled;
state.required = element.required;
state.selectedIndex = element.selectedIndex;
state.selectedOptions = Array.from(element.selectedOptions).map(option => ({
value: option.value,
text: option.text,
index: option.index
}));
}
if (element instanceof HTMLTextAreaElement) {
state.disabled = element.disabled;
state.readOnly = element.readOnly;
state.required = element.required;
state.valid = element.validity.valid;
state.validationMessage = element.validationMessage;
}
return state;
}
}
BehaviorTracker.STORAGE_KEY = 'web_behavior_tracker_session';
BehaviorTracker.THROTTLE_DELAY = 100; // 100ms throttle delay
//# sourceMappingURL=BehaviorTracker.js.map