@varia-bly/variably-sdk
Version:
Official JavaScript/TypeScript SDK for Variably feature flags, experimentation, LLM experiments with React hooks, and real-time dynamic configurations
543 lines • 19.6 kB
JavaScript
/**
* Automatic event tracking for Variably SDK
* Based on the comprehensive metrics collection plan in docs/concepts/metrics-collection.md
*/
export class AutoTracker {
constructor(config = {}, trackEventCallback, trackExperimentMetricCallback, getUserContext) {
this.currentExperimentContext = new Map(); // gate_key -> experiment_id
this.maxScrollDepth = 0;
this.isTracking = false;
/**
* Handle click events
*/
this.handleClick = (event) => {
const target = event.target;
if (!target || !this.shouldTrackClick(target)) {
return;
}
const clickData = this.extractClickData(target);
this.trackEvent({
name: 'user_click',
properties: {
element_type: target.tagName.toLowerCase(),
element_text: target.textContent?.substring(0, 100) || '',
element_id: target.id || undefined,
element_class: target.className || undefined,
page_url: window.location.href,
session_id: this.sessionId,
...clickData,
...this.getExperimentContext()
}
});
// Check for conversion tracking
if (this.config.conversionTracking?.enabled) {
this.checkAndTrackConversions(target);
}
};
/**
* Handle scroll events
*/
this.handleScroll = () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
const scrollPercent = Math.round((scrollTop / scrollHeight) * 100);
if (scrollPercent > this.maxScrollDepth) {
this.maxScrollDepth = scrollPercent;
// Track scroll thresholds
for (const threshold of this.config.scrollThresholds) {
if (scrollPercent >= threshold && this.maxScrollDepth - scrollPercent < threshold) {
this.trackEvent({
name: 'scroll_depth',
properties: {
scroll_depth: threshold,
max_scroll_depth: scrollPercent,
page_url: window.location.href,
session_id: this.sessionId,
...this.getExperimentContext()
}
});
break;
}
}
}
};
/**
* Handle form submissions
*/
this.handleFormSubmit = (event) => {
const form = event.target;
if (!form || this.shouldExcludeElement(form)) {
return;
}
this.trackEvent({
name: 'form_submitted',
properties: {
form_id: form.id || undefined,
form_action: form.action || undefined,
form_method: form.method || 'get',
page_url: window.location.href,
session_id: this.sessionId,
...this.getExperimentContext()
}
});
};
/**
* Handle page visibility changes
*/
this.handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
this.trackSessionPause();
}
else if (document.visibilityState === 'visible') {
this.trackSessionResume();
}
};
/**
* Handle page unload
*/
this.handlePageUnload = () => {
this.trackSessionEnd();
};
/**
* Handle page navigation changes (SPA)
*/
this.handlePageChange = () => {
if (this.config.pageViews) {
// Track page view for new page
setTimeout(() => this.trackPageView(), 100);
}
};
this.config = { ...AutoTracker.DEFAULT_CONFIG, ...config };
this.trackEventCallback = trackEventCallback;
this.trackExperimentMetricCallback = trackExperimentMetricCallback;
this.getUserContext = getUserContext;
this.sessionStartTime = Date.now();
this.sessionId = this.generateSessionId();
}
/**
* Start automatic tracking
*/
start() {
if (this.isTracking || typeof window === 'undefined') {
return;
}
this.isTracking = true;
this.setupEventListeners();
// Track initial page view
if (this.config.pageViews) {
this.trackPageView();
}
}
/**
* Stop automatic tracking
*/
stop() {
if (!this.isTracking) {
return;
}
this.isTracking = false;
this.cleanup();
// Track final session duration
if (this.config.sessionDuration) {
this.trackSessionEnd();
}
}
/**
* Check if tracking is currently active
*/
get isActive() {
return this.isTracking;
}
/**
* Update experiment context when feature gates are evaluated
*/
updateExperimentContext(gateKey, experimentId) {
if (experimentId) {
this.currentExperimentContext.set(gateKey, experimentId);
}
}
/**
* Setup all event listeners for automatic tracking
*/
setupEventListeners() {
// Click tracking
if (this.config.clicks) {
document.addEventListener('click', this.handleClick, true);
}
// Scroll depth tracking
if (this.config.scrollDepth) {
window.addEventListener('scroll', this.handleScroll, { passive: true });
}
// Form submission tracking
if (this.config.formSubmissions) {
document.addEventListener('submit', this.handleFormSubmit, true);
}
// Page visibility changes for session tracking
document.addEventListener('visibilitychange', this.handleVisibilityChange);
// Page unload for session duration
window.addEventListener('beforeunload', this.handlePageUnload);
// Page navigation (SPA support)
window.addEventListener('popstate', this.handlePageChange);
// Override pushState and replaceState for SPA navigation
this.interceptHistoryAPI();
}
/**
* Clean up event listeners
*/
cleanup() {
document.removeEventListener('click', this.handleClick, true);
window.removeEventListener('scroll', this.handleScroll);
document.removeEventListener('submit', this.handleFormSubmit, true);
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
window.removeEventListener('beforeunload', this.handlePageUnload);
window.removeEventListener('popstate', this.handlePageChange);
}
/**
* Track page view event
*/
trackPageView() {
this.trackEvent({
name: 'page_view',
properties: {
page_url: window.location.href,
page_title: document.title,
referrer: document.referrer || undefined,
session_id: this.sessionId,
viewport_width: window.innerWidth,
viewport_height: window.innerHeight,
user_agent: navigator.userAgent,
...this.getExperimentContext()
}
});
}
/**
* Check and track conversion events based on configuration
*/
checkAndTrackConversions(element) {
const conversionEvents = this.config.conversionTracking?.conversionEvents || [];
for (const conversionEvent of conversionEvents) {
if (element.matches?.(conversionEvent.selector) || element.closest(conversionEvent.selector)) {
this.trackConversionEvent(element, conversionEvent);
break; // Only track the first matching conversion event
}
}
}
/**
* Track a specific conversion event
*/
trackConversionEvent(element, conversionEvent) {
const properties = {
session_id: this.sessionId,
page_url: window.location.href,
...this.getExperimentContext()
};
// Extract configured properties
if (conversionEvent.properties) {
for (const [propName, extraction] of Object.entries(conversionEvent.properties)) {
const value = this.extractPropertyValue(element, extraction);
if (value !== undefined) {
properties[propName] = value;
}
}
}
// Add data extraction if configured
const dataExtraction = this.config.conversionTracking?.dataExtraction;
if (dataExtraction?.trackPosition) {
properties.position_in_list = this.getElementPosition(element);
}
if (dataExtraction?.trackLayoutType) {
properties.layout_type = this.getLayoutType(dataExtraction.layoutSelectors);
}
this.trackEvent({
name: conversionEvent.eventName,
properties
});
}
/**
* Track session end
*/
trackSessionEnd() {
const sessionDuration = Date.now() - this.sessionStartTime;
if (sessionDuration >= this.config.minSessionDuration) {
this.trackEvent({
name: 'session_end',
properties: {
session_duration: sessionDuration,
session_id: this.sessionId,
max_scroll_depth: this.maxScrollDepth,
page_url: window.location.href,
...this.getExperimentContext()
}
});
}
}
/**
* Track session pause
*/
trackSessionPause() {
const sessionDuration = Date.now() - this.sessionStartTime;
this.trackEvent({
name: 'session_pause',
properties: {
session_duration: sessionDuration,
session_id: this.sessionId,
page_url: window.location.href,
...this.getExperimentContext()
}
});
}
/**
* Track session resume
*/
trackSessionResume() {
this.trackEvent({
name: 'session_resume',
properties: {
session_id: this.sessionId,
page_url: window.location.href,
...this.getExperimentContext()
}
});
}
/**
* Check if click should be tracked
*/
shouldTrackClick(element) {
// Check exclude selectors first
if (this.shouldExcludeElement(element)) {
return false;
}
// Check if element matches tracking selectors
return this.config.clickSelectors.some(selector => element.matches?.(selector) || element.closest(selector));
}
/**
* Check if element should be excluded from tracking
*/
shouldExcludeElement(element) {
return this.config.excludeSelectors.some(selector => element.matches?.(selector) || element.closest(selector));
}
/**
* Extract click data from element
*/
extractClickData(element) {
return {
href: element.getAttribute('href') || undefined,
data_track: element.getAttribute('data-track') || undefined,
xpath: this.getElementXPath(element)
};
}
/**
* Extract property value based on configuration
*/
extractPropertyValue(element, extraction) {
const targetElement = extraction.selector ?
element.querySelector(extraction.selector) || element.closest(extraction.selector) :
element;
if (!targetElement)
return undefined;
let value;
// Extract value based on attribute
switch (extraction.attribute) {
case 'textContent':
value = targetElement.textContent?.trim();
break;
case 'href':
value = targetElement.href;
break;
case 'value':
value = targetElement.value;
break;
default:
// Handle data-* attributes or other attributes
value = targetElement.getAttribute(extraction.attribute);
break;
}
// Apply transforms
if (value && extraction.transform) {
switch (extraction.transform) {
case 'number':
const parsed = parseFloat(value);
value = isNaN(parsed) ? undefined : parsed;
break;
case 'trim':
value = String(value).trim();
break;
case 'toLowerCase':
value = String(value).toLowerCase();
break;
case 'toUpperCase':
value = String(value).toUpperCase();
break;
}
}
return value;
}
/**
* Get element position in its container
*/
getElementPosition(element) {
const parent = element.parentElement;
if (!parent)
return undefined;
const siblings = Array.from(parent.children).filter(child => child.tagName === element.tagName);
const position = siblings.indexOf(element) + 1;
return position > 0 ? position : undefined;
}
/**
* Get current layout type based on configuration
*/
getLayoutType(layoutSelectors = {}) {
// Check configured layout selectors
for (const [layoutName, selector] of Object.entries(layoutSelectors)) {
if (document.querySelector(selector)) {
return layoutName;
}
}
return undefined;
}
/**
* Get current experiment context
*/
getExperimentContext() {
const context = {};
for (const [gateKey, experimentId] of this.currentExperimentContext.entries()) {
context[`${gateKey}_experiment_id`] = experimentId;
}
return context;
}
/**
* Generate unique session ID
*/
generateSessionId() {
return `sess_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Get XPath for element (for precise tracking)
*/
getElementXPath(element) {
if (element.id) {
return `//*[@id="${element.id}"]`;
}
const parts = [];
let current = element;
while (current && current !== document.body) {
const tagName = current.tagName.toLowerCase();
const siblings = Array.from(current.parentElement?.children || [])
.filter(el => el.tagName === current.tagName);
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1;
parts.unshift(`${tagName}[${index}]`);
}
else {
parts.unshift(tagName);
}
current = current.parentElement;
}
return '/' + parts.join('/');
}
/**
* Track event using the provided callback, with experiment attribution when applicable
*/
async trackEvent(event) {
try {
const userContext = this.getUserContext();
if (!userContext?.userId) {
throw new Error('User context with userId is required for auto-tracking events');
}
// Check if user has any active experiment contexts and track as experiment metrics
const experimentContextEntries = Array.from(this.currentExperimentContext.entries());
if (experimentContextEntries.length > 0) {
// Track as experiment metrics for all active experiments
for (const [gateKey, experimentId] of experimentContextEntries) {
await this.trackExperimentMetricCallback(experimentId, {
userId: userContext.userId,
metricKey: event.name,
value: this.getEventValue(event),
sessionId: this.sessionId,
timestamp: new Date(),
metadata: {
...event.properties,
gate_key: gateKey,
source: 'auto_tracker'
}
});
}
}
else {
// Track as regular analytics event
await this.trackEventCallback({
...event,
userId: userContext.userId,
timestamp: new Date(),
context: userContext
});
}
}
catch (error) {
// Silently fail to not break user experience
console.warn('Auto-tracker failed to send event:', error);
}
}
/**
* Extract numeric value from event for experiment metrics
*/
getEventValue(event) {
// For most events, we track them as count = 1
// Special cases can be handled based on event name or properties
switch (event.name) {
case 'scroll_depth':
return Number(event.properties?.scroll_depth) || 1;
case 'session_end':
return Number(event.properties?.session_duration) || 1;
case 'session_pause':
return Number(event.properties?.session_duration) || 1;
default:
return 1; // Default count for events like clicks, page views, form submissions
}
}
/**
* Intercept History API for SPA navigation detection
*/
interceptHistoryAPI() {
const originalPushState = window.history.pushState.bind(window.history);
const originalReplaceState = window.history.replaceState.bind(window.history);
window.history.pushState = (data, unused, url) => {
originalPushState(data, unused, url);
setTimeout(() => {
if (this.config?.pageViews) {
this.trackPageView();
}
}, 100);
};
window.history.replaceState = (data, unused, url) => {
originalReplaceState(data, unused, url);
setTimeout(() => {
if (this.config?.pageViews) {
this.trackPageView();
}
}, 100);
};
}
}
// Default configuration
AutoTracker.DEFAULT_CONFIG = {
pageViews: true,
clicks: true,
scrollDepth: true,
sessionDuration: true,
formSubmissions: false,
clickSelectors: ['button', 'a', '[data-track]'],
excludeSelectors: ['.no-track', '[data-no-track]'],
minSessionDuration: 30000, // 30 seconds
scrollThresholds: [25, 50, 75, 90],
conversionTracking: {
enabled: false,
conversionEvents: [],
dataExtraction: {
trackPosition: false,
trackLayoutType: false,
layoutSelectors: {}
}
}
};
//# sourceMappingURL=auto-tracker.js.map