@tinytapanalytics/sdk
Version:
Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time
421 lines (353 loc) • 11.7 kB
text/typescript
/**
* Auto-tracking features for automatic event collection
* Dynamically imported to reduce core bundle size
*/
import { TinyTapAnalyticsConfig } from '../types/index';
export class AutoTracking {
private config: TinyTapAnalyticsConfig;
private sdk: any;
private observers: Set<MutationObserver | IntersectionObserver> = new Set();
private listeners: Set<{ element: EventTarget; event: string; handler: EventListener }> = new Set();
private isActive = false;
constructor(config: TinyTapAnalyticsConfig, sdk: any) {
this.config = config;
this.sdk = sdk;
}
/**
* Start auto-tracking
*/
public start(): void {
if (this.isActive) {
return;
}
this.isActive = true;
this.setupClickTracking();
this.setupFormTracking();
this.setupScrollTracking();
this.setupElementVisibilityTracking();
this.setupPageEngagementTracking();
this.setupErrorTracking();
if (this.config.debug) {
console.log('TinyTapAnalytics: Auto-tracking started');
}
}
/**
* Stop auto-tracking
*/
public stop(): void {
if (!this.isActive) {
return;
}
this.isActive = false;
// Clean up observers
this.observers.forEach(observer => observer.disconnect());
this.observers.clear();
// Clean up event listeners
this.listeners.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler);
});
this.listeners.clear();
if (this.config.debug) {
console.log('TinyTapAnalytics: Auto-tracking stopped');
}
}
/**
* Setup automatic click tracking
*/
private setupClickTracking(): void {
const clickHandler = (event: Event) => {
const target = event.target as Element;
if (this.shouldTrackClick(target)) {
this.sdk.trackClick(target, {
auto_tracked: true,
coordinates: { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY },
timestamp: Date.now()
});
}
};
document.addEventListener('click', clickHandler, true);
this.listeners.add({ element: document, event: 'click', handler: clickHandler });
}
/**
* Setup automatic form tracking
*/
private setupFormTracking(): void {
// Track form submissions
const submitHandler = (event: Event) => {
const form = event.target as HTMLFormElement;
this.sdk.track('form_submit', {
form_id: form.id || null,
form_action: form.action || null,
form_method: form.method || 'get',
form_fields: this.getFormFields(form),
auto_tracked: true
});
};
// Track form field interactions
const fieldHandler = (event: Event) => {
const field = event.target as HTMLInputElement;
const form = field.closest('form');
if (form && this.shouldTrackFormField(field)) {
this.sdk.track('form_field_interaction', {
field_name: field.name || null,
field_type: field.type || null,
field_id: field.id || null,
form_id: form.id || null,
event_type: event.type,
auto_tracked: true
});
}
};
document.addEventListener('submit', submitHandler, true);
document.addEventListener('focus', fieldHandler, true);
document.addEventListener('blur', fieldHandler, true);
this.listeners.add({ element: document, event: 'submit', handler: submitHandler });
this.listeners.add({ element: document, event: 'focus', handler: fieldHandler });
this.listeners.add({ element: document, event: 'blur', handler: fieldHandler });
}
/**
* Setup scroll depth tracking
*/
private setupScrollTracking(): void {
let maxScrollDepth = 0;
let scrollTimeout: number;
const scrollHandler = () => {
clearTimeout(scrollTimeout);
scrollTimeout = window.setTimeout(() => {
const scrollDepth = this.getScrollDepth();
if (scrollDepth > maxScrollDepth) {
const previousDepth = maxScrollDepth;
maxScrollDepth = scrollDepth;
// Track scroll milestones
const milestones = [25, 50, 75, 90];
const crossedMilestone = milestones.find(
milestone => previousDepth < milestone && scrollDepth >= milestone
);
if (crossedMilestone) {
this.sdk.track('scroll_depth', {
depth: crossedMilestone,
auto_tracked: true,
page_height: document.documentElement.scrollHeight,
viewport_height: window.innerHeight
});
}
}
}, 250);
};
window.addEventListener('scroll', scrollHandler, { passive: true });
this.listeners.add({ element: window, event: 'scroll', handler: scrollHandler });
}
/**
* Setup element visibility tracking
*/
private setupElementVisibilityTracking(): void {
if (!('IntersectionObserver' in window)) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const element = entry.target;
const selector = this.getElementSelector(element);
this.sdk.track('element_view', {
element: selector,
element_type: element.tagName.toLowerCase(),
visibility_ratio: entry.intersectionRatio,
auto_tracked: true
});
}
});
},
{ threshold: [0.5] }
);
// Observe trackable elements
const trackableElements = document.querySelectorAll('[data-track-view]');
trackableElements.forEach(element => observer.observe(element));
this.observers.add(observer);
// Watch for new elements
this.setupDynamicElementTracking(observer);
}
/**
* Setup page engagement tracking
*/
private setupPageEngagementTracking(): void {
let startTime = Date.now();
let isVisible = !document.hidden;
let totalEngagementTime = 0;
const visibilityHandler = () => {
const now = Date.now();
if (document.hidden) {
if (isVisible) {
totalEngagementTime += now - startTime;
isVisible = false;
}
} else {
if (!isVisible) {
startTime = now;
isVisible = true;
}
}
};
const beforeUnloadHandler = () => {
const now = Date.now();
if (isVisible) {
totalEngagementTime += now - startTime;
}
this.sdk.track('page_engagement', {
total_time: totalEngagementTime,
page_url: window.location.href,
auto_tracked: true
});
};
document.addEventListener('visibilitychange', visibilityHandler);
window.addEventListener('beforeunload', beforeUnloadHandler);
this.listeners.add({ element: document, event: 'visibilitychange', handler: visibilityHandler });
this.listeners.add({ element: window, event: 'beforeunload', handler: beforeUnloadHandler });
}
/**
* Setup error tracking
*/
private setupErrorTracking(): void {
const errorHandler = (event: Event) => {
const errorEvent = event as ErrorEvent;
this.sdk.track('javascript_error', {
message: errorEvent.message,
filename: errorEvent.filename,
lineno: errorEvent.lineno,
colno: errorEvent.colno,
stack: errorEvent.error?.stack,
auto_tracked: true
});
};
const rejectionHandler = (event: Event) => {
const rejectionEvent = event as PromiseRejectionEvent;
this.sdk.track('promise_rejection', {
reason: rejectionEvent.reason?.toString() || 'Unknown',
auto_tracked: true
});
};
window.addEventListener('error', errorHandler);
window.addEventListener('unhandledrejection', rejectionHandler);
this.listeners.add({ element: window, event: 'error', handler: errorHandler });
this.listeners.add({ element: window, event: 'unhandledrejection', handler: rejectionHandler });
}
/**
* Setup tracking for dynamically added elements
*/
private setupDynamicElementTracking(intersectionObserver: IntersectionObserver): void {
if (!('MutationObserver' in window)) {
return;
}
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
// Track new elements with data-track-view
if (element.hasAttribute('data-track-view')) {
intersectionObserver.observe(element);
}
// Check child elements
const trackableChildren = element.querySelectorAll('[data-track-view]');
trackableChildren.forEach(child => intersectionObserver.observe(child));
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
this.observers.add(observer);
}
/**
* Determine if click should be tracked
*/
private shouldTrackClick(element: Element): boolean {
const tagName = element.tagName.toLowerCase();
// Always track buttons and links
if (tagName === 'button' || tagName === 'a') {
return true;
}
// Track elements with data-track attribute
if (element.hasAttribute('data-track') || element.hasAttribute('data-track-click')) {
return true;
}
// Track elements with common CTA classes
const classList = Array.from(element.classList);
const ctaKeywords = ['btn', 'button', 'cta', 'submit', 'checkout', 'buy', 'purchase', 'download'];
return ctaKeywords.some(keyword =>
classList.some(className => className.toLowerCase().includes(keyword))
);
}
/**
* Determine if form field should be tracked
*/
private shouldTrackFormField(field: HTMLInputElement): boolean {
// Don't track password fields for privacy
if (field.type === 'password') {
return false;
}
// Don't track if explicitly disabled
if (field.hasAttribute('data-track-disable')) {
return false;
}
return true;
}
/**
* Get form field data
*/
private getFormFields(form: HTMLFormElement): string[] {
const fields: string[] = [];
const formData = new FormData(form);
for (const [name] of formData.entries()) {
if (!fields.includes(name)) {
fields.push(name);
}
}
return fields;
}
/**
* Get scroll depth percentage
*/
private getScrollDepth(): number {
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
return Math.round((scrollTop / (documentHeight - windowHeight)) * 100);
}
/**
* Get element selector
*/
private getElementSelector(element: Element): string {
if (element.id) {
return `#${element.id}`;
}
if (element.className) {
const classes = Array.from(element.classList).join('.');
return `.${classes}`;
}
const parent = element.parentElement;
if (parent) {
const siblings = Array.from(parent.children);
const index = siblings.indexOf(element) + 1;
return `${element.tagName.toLowerCase()}:nth-child(${index})`;
}
return element.tagName.toLowerCase();
}
/**
* Get current auto-tracking statistics
*/
public getStats(): {
isActive: boolean;
observers: number;
listeners: number;
} {
return {
isActive: this.isActive,
observers: this.observers.size,
listeners: this.listeners.size
};
}
}