@cruxstack/browser-sdk
Version:
A lightweight, privacy-focused JavaScript SDK for web analytics and event tracking. Built with TypeScript, featuring automatic event capture, event-time environment snapshots, intelligent queuing, and robust error handling.
1,410 lines (1,394 loc) • 55.9 kB
JavaScript
let debugEnabled = false;
function setDebugLog(enabled) {
debugEnabled = !!enabled;
}
function debug(message, extra) {
if (!debugEnabled)
return;
if (extra !== undefined) {
console.log("Cruxstack:", message, extra);
}
else {
console.log("Cruxstack:", message);
}
}
function error(message, err) {
if (err instanceof Error) {
console.error("Cruxstack:", message, err);
}
else if (err !== undefined) {
console.error("Cruxstack:", message, String(err));
}
else {
console.error("Cruxstack:", message);
}
}
function formatErrorMessage(context, err) {
const base = `Cruxstack: ${context}`;
if (err instanceof Error)
return `${base}: ${err.message}`;
try {
return `${base}: ${String(err)}`;
}
catch {
return base;
}
}
// Session configuration
const SESSION_DURATION = 30 * 60 * 1000; // 30 minutes of inactivity
const MAX_SESSION_DURATION = 4 * 60 * 60 * 1000; // 4 hours maximum session
const SESSION_STORAGE_KEY = 'cruxstack_session';
// Memory storage fallback
class MemoryStorage {
constructor() {
this.data = new Map();
}
getItem(key) {
return this.data.get(key) || null;
}
setItem(key, value) {
this.data.set(key, value);
}
removeItem(key) {
this.data.delete(key);
}
}
// Storage detection with fallbacks
function getStorage() {
// Try sessionStorage first
try {
sessionStorage.setItem('test', '1');
sessionStorage.removeItem('test');
return sessionStorage;
}
catch (e) {
// Fallback to memory storage
return new MemoryStorage();
}
}
class SessionManager {
constructor(config) {
this.config = config;
this.storage = getStorage();
this.debugLog = config.debugLog || false;
// Warn if using memory storage
if (this.storage instanceof MemoryStorage && this.debugLog) {
console.warn('Cruxstack: Using memory storage fallback. Sessions will not persist across page reloads.');
}
}
getSessionId() {
const sessionData = this.getSessionData();
const now = Date.now();
if (!sessionData || this.shouldExpireSession(sessionData, now)) {
return this.createNewSession();
}
// Update last activity
sessionData.lastActivity = now;
this.saveSessionData(sessionData);
return sessionData.id;
}
getUserId() {
// Always use userId from config, never store it
return this.config.userId;
}
resetSession() {
this.storage.removeItem(SESSION_STORAGE_KEY);
if (this.debugLog) {
console.log('Cruxstack: Session reset successfully');
}
}
getSessionData() {
try {
const data = this.storage.getItem(SESSION_STORAGE_KEY);
return data ? JSON.parse(data) : null;
}
catch (e) {
if (this.debugLog) {
console.error('Cruxstack: Error reading session data:', e);
}
return null;
}
}
saveSessionData(sessionData) {
try {
this.storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessionData));
}
catch (e) {
if (this.debugLog) {
console.error('Cruxstack: Error saving session data:', e);
}
}
}
shouldExpireSession(sessionData, now) {
const sessionAge = now - sessionData.startTime;
const timeSinceLastActivity = now - sessionData.lastActivity;
// Expire if session is too old OR user has been inactive
return sessionAge > MAX_SESSION_DURATION ||
timeSinceLastActivity > SESSION_DURATION;
}
createNewSession() {
const now = Date.now();
const sessionData = {
id: this.generateId(),
startTime: now,
lastActivity: now
};
this.saveSessionData(sessionData);
if (this.debugLog) {
console.log('Cruxstack: New session created:', sessionData.id);
}
return sessionData.id;
}
generateId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}
const BATCH_SIZE = 10;
const QUEUE_KEY = 'cruxstack';
const MAX_QUEUE_SIZE = 1000; // Maximum events to store
class EventQueue {
constructor() {
this.queue = [];
this.loadFromStorage();
}
add(event) {
// Prevent queue from growing too large
if (this.queue.length >= MAX_QUEUE_SIZE) {
this.queue.shift(); // Remove oldest event
}
this.queue.push(event);
this.saveToStorage();
}
getBatch() {
const batch = this.queue.splice(0, BATCH_SIZE);
this.saveToStorage();
return batch;
}
remove(eventId) {
this.queue = this.queue.filter(e => e.id !== eventId);
this.saveToStorage();
}
// Debug method to check queue status
getQueueStatus() {
return {
length: this.queue.length,
events: this.queue
};
}
// Debug method to clear queue
clearQueue() {
this.queue = [];
this.saveToStorage();
}
addMany(events) {
for (const e of events)
this.add(e);
}
saveToStorage() {
try {
// Only persist events that need to be sent
const queueData = JSON.stringify(this.queue);
localStorage.setItem(QUEUE_KEY, queueData);
}
catch (e) {
console.error('EventQueue: Storage failed:', e);
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
// Handle storage full error by rotating oldest events
this.queue = this.queue.slice(-Math.floor(MAX_QUEUE_SIZE / 2));
this.saveToStorage();
}
else {
console.error('Queue storage failed', e);
}
}
}
loadFromStorage() {
try {
const stored = localStorage.getItem(QUEUE_KEY);
this.queue = stored ? JSON.parse(stored) : [];
}
catch (e) {
console.error('EventQueue: Load failed:', e);
this.queue = [];
}
}
}
class ApiClient {
constructor(clientId, customerId, customerName, debugLog = false) {
this.ipAddress = null;
this.ipFetchInFlight = null;
this.endpoint = "https://api.cruxstack.com/api/v1/events";
this.debugLog = debugLog;
setDebugLog(this.debugLog);
this.clientId = clientId;
this.customerId = customerId;
this.customerName = customerName;
// Eagerly resolve IP address client-side (best-effort)
this.fetchIpAddress();
}
// GET request method
async get(path, params = {}, options = {}) {
try {
// Build URL with query parameters
let url = `https://api.cruxstack.com/backend/v1/${path}`;
if (Object.keys(params).length > 0) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
url += `?${searchParams.toString()}`;
}
// Build headers
const headers = {
"Content-Type": "application/json",
"x-client-id": this.clientId,
...options.customHeaders,
};
// Add customerId to headers if available
if (this.customerId) {
headers["x-customer-id"] = this.customerId;
}
// Add userId to headers if provided
if (options.userId) {
headers["x-user-id"] = options.userId;
}
const requestOptions = {
method: "POST",
//need to change this to GET.
headers,
};
debug("GET Request", { url, headers });
const response = await fetch(url, requestOptions);
return await this.handleResponse(response);
}
catch (error$1) {
error("GET Request Failed", error$1);
throw new Error(formatErrorMessage("GET request failed", error$1));
}
}
// POST request method
async post(path, data = {}, options = {}) {
try {
const url = `${this.endpoint}/${path}`;
// Build headers
const headers = {
"Content-Type": "application/json",
"x-client-id": this.clientId,
...options.customHeaders,
};
// Add customerId to headers if available
if (this.customerId) {
headers["x-customer-id"] = this.customerId;
}
// Add userId to headers if provided
if (options.userId) {
headers["x-user-id"] = options.userId;
}
const requestOptions = {
method: "POST",
headers,
body: JSON.stringify(data),
};
debug("POST Request", { url, data, headers });
const response = await fetch(url, requestOptions);
return await this.handleResponse(response);
}
catch (error$1) {
error("POST Request Failed", error$1);
throw new Error(formatErrorMessage("POST request failed", error$1));
}
}
// PUT request method
async put(path, data = {}, options = {}) {
try {
const url = `${this.endpoint}/${path}`;
// Build headers
const headers = {
"Content-Type": "application/json",
"x-client-id": this.clientId,
...options.customHeaders,
};
// Add customerId to headers if available
if (this.customerId) {
headers["x-customer-id"] = this.customerId;
}
// Add userId to headers if provided
if (options.userId) {
headers["x-user-id"] = options.userId;
}
const requestOptions = {
method: "PUT",
headers,
body: JSON.stringify(data),
credentials: "include",
};
debug("PUT Request", { url, data, headers });
const response = await fetch(url, requestOptions);
return await this.handleResponse(response);
}
catch (error$1) {
error("PUT Request Failed", error$1);
throw new Error(formatErrorMessage("PUT request failed", error$1));
}
}
// DELETE request method
async delete(path, options = {}) {
try {
const url = `${this.endpoint}/${path}`;
// Build headers
const headers = {
"Content-Type": "application/json",
"x-client-id": this.clientId,
...options.customHeaders,
};
// Add customerId to headers if available
if (this.customerId) {
headers["x-customer-id"] = this.customerId;
}
// Add userId to headers if provided
if (options.userId) {
headers["x-user-id"] = options.userId;
}
const requestOptions = {
method: "DELETE",
headers,
credentials: "include",
};
debug("DELETE Request", { url, headers });
const response = await fetch(url, requestOptions);
return await this.handleResponse(response);
}
catch (error$1) {
error("DELETE Request Failed", error$1);
throw new Error(formatErrorMessage("DELETE request failed", error$1));
}
}
// Handle API response with proper error handling
async handleResponse(response) {
// Success path
if (response.ok) {
// 204 No Content
if (response.status === 204) {
return undefined;
}
// Try to detect JSON by header
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
if (contentType.includes("application/json") || contentType.includes("json")) {
try {
const data = await response.json();
return data;
}
catch {
// Fall back to text if body is not valid JSON
const text = await response.text();
return text;
}
}
// Non-JSON success bodies (e.g., plain "ok")
const text = await response.text();
return text;
}
// Error path
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const maybeJson = await response.json();
if (maybeJson && (maybeJson.message || maybeJson.error)) {
errorMessage = maybeJson.message || maybeJson.error;
}
}
catch {
try {
const text = await response.text();
if (text)
errorMessage = text;
}
catch { }
}
throw new Error(errorMessage);
}
// Specific method for user traits by customerId
async getUserTraits(customerId) {
return this.get(`users/traits?customerId=${customerId}`);
}
// Send events to backend
async sendEvent(event) {
try {
// Use sendBeacon when in unload scenario (best-effort, non-blocking)
if (this.isUnloadScenario() && 'sendBeacon' in navigator) {
if (!event.env) {
throw new Error("Event missing env snapshot");
}
const apiEvent = {
cid: event.customerId,
cna: event.customerName,
uid: event.userId,
eid: event.id,
dtm: event.timestamp,
e: event.type,
ev: event.data,
tv: "v1",
sid: event.sessionId,
tna: "web",
...event.env,
};
const payload = JSON.stringify({ events: [apiEvent] });
const ok = navigator.sendBeacon?.(`${this.endpoint.replace(/\/$/, '')}`, new Blob([payload], { type: 'application/json' }));
debug("sendBeacon invoked", { ok });
return !!ok;
}
if (!event.env) {
throw new Error("Event missing env snapshot");
}
const env = event.env;
const apiEvent = {
cid: event.customerId,
cna: event.customerName,
uid: event.userId, // may be undefined per requirement
eid: event.id,
dtm: event.timestamp,
e: event.type,
ev: event.data,
tv: "v1",
sid: event.sessionId,
tna: "web",
...env,
};
debug("Sending event", { events: [apiEvent] });
// Use the post method
await this.post("", { events: [apiEvent] });
debug("Event sent successfully");
return true;
}
catch (error$1) {
error("Failed to send event", error$1);
throw error$1;
}
}
// Send a batch of events (optimized for queue flush)
async sendEventsBatch(events) {
if (!events.length)
return true;
// Validate env presence
for (const e of events) {
if (!e.env) {
throw new Error("Event missing env snapshot");
}
}
const apiEvents = events.map((event) => ({
cid: event.customerId,
cna: event.customerName,
uid: event.userId,
eid: event.id,
dtm: event.timestamp,
e: event.type,
ev: event.data,
tv: "v1",
sid: event.sessionId,
tna: "web",
...event.env,
}));
// Unload scenario: attempt sendBeacon with the whole batch
if (this.isUnloadScenario() && 'sendBeacon' in navigator) {
const payload = JSON.stringify({ events: apiEvents });
const ok = navigator.sendBeacon?.(`${this.endpoint.replace(/\/$/, '')}`, new Blob([payload], { type: 'application/json' }));
debug("sendBeacon batch invoked", { ok, count: apiEvents.length });
return !!ok;
}
debug("Sending events batch", { count: apiEvents.length });
await this.post("", { events: apiEvents });
return true;
}
isUnloadScenario() {
// Only treat true unload visibility as unload scenario. Being offline should not force sendBeacon.
return document.visibilityState === "hidden" || ("sendBeacon" in navigator === false);
}
// buildEnvironmentFields is intentionally unused; env is captured at event-time via captureEnvSnapshot
// Best-effort client-side IP fetch; cached for subsequent events
fetchIpAddress() {
if (this.ipFetchInFlight)
return;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
this.ipFetchInFlight = fetch("https://api.ipify.org?format=json", {
signal: controller.signal,
credentials: "omit",
})
.then(async (res) => {
clearTimeout(timeoutId);
if (!res.ok)
return;
const data = await res.json();
if (data && typeof data.ip === "string") {
this.ipAddress = data.ip;
}
})
.catch(() => { })
.finally(() => {
this.ipFetchInFlight = null;
});
}
// Expose cached IP for snapshotting at event time
getCachedIp() {
return this.ipAddress;
}
}
function captureEnvSnapshot(userId, ipAddress) {
let pageLoadtime = null;
try {
// Prefer modern Navigation Timing API
const navEntries = (performance && performance.getEntriesByType) ? performance.getEntriesByType('navigation') : [];
const nav = (navEntries && navEntries[0]);
if (nav && typeof nav.loadEventEnd === 'number' && typeof nav.startTime === 'number') {
// loadEventEnd is relative to startTime for PerformanceNavigationTiming
pageLoadtime = Math.max(0, Math.round(nav.loadEventEnd - nav.startTime));
}
else {
// Fallback to deprecated performance.timing when nav entry is unavailable
const timing = (performance && performance.timing) || null;
if (timing && timing.loadEventEnd > 0 && timing.navigationStart > 0) {
pageLoadtime = timing.loadEventEnd - timing.navigationStart;
}
}
}
catch { }
return {
ua: navigator.userAgent,
sh: typeof screen !== 'undefined' ? screen.height : 0,
sw: typeof screen !== 'undefined' ? screen.width : 0,
l: navigator.language,
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
p: navigator.platform,
an: !userId,
vh: typeof window !== 'undefined' ? window.innerHeight : 0,
vw: typeof window !== 'undefined' ? window.innerWidth : 0,
pt: typeof document !== 'undefined' ? document.title : '',
pu: typeof window !== 'undefined' ? window.location.href : '',
pp: typeof window !== 'undefined' ? window.location.pathname : '',
pd: typeof window !== 'undefined' ? window.location.hostname : '',
pl: pageLoadtime,
pr: typeof document !== 'undefined' && document.referrer ? document.referrer : null,
ip: ipAddress ?? null,
};
}
class EventTracker {
constructor(apiClient, eventQueue, sessionManager, clientId, customerId, customerName) {
this.apiClient = apiClient;
this.eventQueue = eventQueue;
this.sessionManager = sessionManager;
this.clientId = clientId;
this.customerId = customerId;
this.customerName = customerName;
}
async track(eventData) {
// Create complete event with session data
const event = {
...eventData,
clientId: this.clientId,
customerId: this.customerId || "cust-012345",
customerName: this.customerName || "Default Customer",
sessionId: this.sessionManager.getSessionId(),
userId: this.sessionManager.getUserId(),
timestamp: Date.now(),
env: captureEnvSnapshot(this.sessionManager.getUserId(), this.apiClient.getCachedIp?.() ?? null)
};
try {
// First attempt to send immediately
const success = await this.apiClient.sendEvent(event);
if (!success) {
// 400 errors are handled and logged in sendEvent
return;
}
}
catch (error) {
// On any error, persist once; flushing handles batches
this.eventQueue.add(event);
}
}
// Method to manually flush the queue
async flushQueue() {
while (true) {
const batch = this.eventQueue.getBatch();
if (batch.length === 0)
return;
try {
const success = await this.apiClient.sendEventsBatch(batch);
if (!success) {
// Put the batch back once and stop to avoid tight loop
this.eventQueue.addMany(batch);
return;
}
}
catch (error) {
// Put the batch back once and stop; user/app can retry later
this.eventQueue.addMany(batch);
return;
}
}
}
// Debug method to check queue status
getQueueStatus() {
return this.eventQueue.getQueueStatus();
}
// Debug method to clear queue
clearQueue() {
this.eventQueue.clearQueue();
}
}
// Static properties that don't change during a session
// Utility to generate CSS selector for an element
const getElementSelector = (element, maxDepth = 5) => {
if (!element || element === document.body)
return 'body';
const path = [];
let current = element;
let depth = 0;
while (current && current !== document.body && depth < maxDepth) {
let selector = current.tagName.toLowerCase();
if (current.id) {
selector += `#${current.id}`;
path.unshift(selector);
break;
}
if (current.className) {
const classes = current.className.split(' ').filter(c => c.trim()).slice(0, 3);
if (classes.length > 0) {
selector += `.${classes.join('.')}`;
}
}
// Add nth-child if there are siblings with same tag
const siblings = current.parentElement?.children;
if (siblings && siblings.length > 1) {
const sameTagSiblings = Array.from(siblings).filter(s => s.tagName === current.tagName);
if (sameTagSiblings.length > 1) {
const index = Array.from(siblings).indexOf(current) + 1;
selector += `:nth-child(${index})`;
}
}
path.unshift(selector);
current = current.parentElement;
depth++;
}
return path.join(' > ');
};
// Utility to safely get text content
const getSafeTextContent = (element, maxLength = 100) => {
const text = element.textContent?.trim();
if (!text)
return null;
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
};
// Sensitive elements that should never be tracked
const SENSITIVE_SELECTORS = [
'input[type="password"]',
'input[type="email"]',
'input[type="tel"]',
'input[type="credit-card"]',
'input[type="cc-number"]',
'input[type="cardnumber"]',
'input[name*="password"]',
'input[name*="ssn"]',
'input[name*="social"]',
'[data-cruxstack-ignore]',
'[autocomplete="cc-number"]',
'[autocomplete="cc-exp"]',
'[autocomplete="cc-csc"]'
].join(',');
// Rate limiting constants
const RATE_LIMITS = {
CLICK_THROTTLE_MS: 100,
FORM_CHANGE_DEBOUNCE_MS: 300,
MAX_TEXT_LENGTH: 100
};
// Check if an element should be ignored for tracking
const shouldIgnoreElement = (element) => {
// Check if element matches sensitive selectors
if (element.matches(SENSITIVE_SELECTORS)) {
return true;
}
// Check if element is inside a sensitive container
if (element.closest(SENSITIVE_SELECTORS)) {
return true;
}
// Check for data attributes that indicate sensitive content
if (element.hasAttribute('data-sensitive') ||
element.hasAttribute('data-private') ||
element.hasAttribute('data-no-track')) {
return true;
}
return false;
};
// Redact sensitive values from form inputs
const redactSensitiveValue = (element, value) => {
const input = element;
const type = input.type?.toLowerCase();
const name = input.name?.toLowerCase();
// Always redact password fields
if (type === 'password') {
return '[REDACTED_PASSWORD]';
}
// Redact email fields
if (type === 'email' || name?.includes('email')) {
return '[REDACTED_EMAIL]';
}
// Redact phone numbers
if (type === 'tel' || name?.includes('phone') || name?.includes('tel')) {
return '[REDACTED_PHONE]';
}
// Redact credit card fields
if (name?.includes('card') || name?.includes('credit') || type?.includes('cc')) {
return '[REDACTED_CARD]';
}
// Redact SSN or social security
if (name?.includes('ssn') || name?.includes('social')) {
return '[REDACTED_SSN]';
}
// For other inputs, return limited info
if (input.tagName === 'INPUT') {
return value.length > 0 ? `[${value.length} chars]` : null;
}
return value;
};
// Clean data object by removing or redacting sensitive information
const cleanEventData = (data) => {
const cleaned = { ...data };
// Remove sensitive fields
const sensitiveFields = ['password', 'ssn', 'social', 'creditCard', 'cvv'];
sensitiveFields.forEach(field => {
if (cleaned[field]) {
delete cleaned[field];
}
});
// Redact URLs that might contain sensitive info
if (cleaned.url && typeof cleaned.url === 'string') {
try {
const url = new URL(cleaned.url);
// Remove sensitive query parameters
const sensitiveParams = ['token', 'key', 'password', 'secret', 'auth'];
sensitiveParams.forEach(param => {
url.searchParams.delete(param);
});
cleaned.url = url.toString();
}
catch (e) {
// Invalid URL, keep as is
}
}
return cleaned;
};
// Check if we should rate limit this event
let eventCounts = new Map();
const shouldRateLimit = (eventType, limit = 100, windowMs = 60000) => {
const now = Date.now();
const key = eventType;
const current = eventCounts.get(key) || { count: 0, lastReset: now };
// Reset counter if window has passed
if (now - current.lastReset > windowMs) {
current.count = 0;
current.lastReset = now;
}
current.count++;
eventCounts.set(key, current);
return current.count > limit;
};
let lastClickTime = 0;
let pageLoadTime$1 = Date.now();
// Throttle function to prevent too many click events
const throttle = (func, delay) => {
let timeoutId = null;
let lastExecTime = 0;
return function (...args) {
const currentTime = Date.now();
if (currentTime - lastExecTime > delay) {
func.apply(this, args);
lastExecTime = currentTime;
}
else {
if (timeoutId)
clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
func.apply(this, args);
lastExecTime = Date.now();
}, delay - (currentTime - lastExecTime));
}
};
};
// Get element attributes (limited set)
const getElementAttributes = (element) => {
const attrs = {};
const allowedAttrs = ['data-testid', 'data-track', 'role', 'aria-label', 'title'];
allowedAttrs.forEach(attr => {
const value = element.getAttribute(attr);
if (value) {
attrs[attr] = value;
}
});
return attrs;
};
// Calculate DOM depth from body
const getDOMDepth = (element) => {
let depth = 0;
let current = element;
while (current && current !== document.body) {
depth++;
current = current.parentElement;
}
return depth;
};
// Process click event and extract data
const processClickEvent = (event) => {
const target = event.target;
// Privacy and safety checks
if (!target || shouldIgnoreElement(target)) {
return null;
}
// Only track clicks on actionable elements
const actionableSelectors = [
'button', 'a', 'input', 'select', 'textarea',
'[role="button"]', '[role="link"]', '[role="tab"]',
'[onclick]', '[data-clickable]', '[data-track]'
];
const isActionable = actionableSelectors.some(selector => target.matches(selector) || target.closest(selector));
if (!isActionable) {
return null; // Skip clicks on non-actionable elements
}
// Rate limiting
if (shouldRateLimit('click', 50, 10000)) { // 50 clicks per 10 seconds max
return null;
}
const now = Date.now();
const timeSinceLastClick = lastClickTime > 0 ? now - lastClickTime : null;
lastClickTime = now;
// Collect click-specific data
const clickData = {
element: {
tag: target.tagName.toLowerCase(),
id: target.id || null,
classes: target.className || null,
text: getSafeTextContent(target, RATE_LIMITS.MAX_TEXT_LENGTH),
href: target.href || null,
selector: getElementSelector(target),
attributes: getElementAttributes(target)
},
position: {
x: event.clientX,
y: event.clientY,
pageX: event.pageX,
pageY: event.pageY
},
context: {
isContentEditable: target.isContentEditable,
hasChildren: target.children.length > 0,
parentTag: target.parentElement?.tagName.toLowerCase() || null,
depth: getDOMDepth(target)
},
timing: {
timeOnPage: now - pageLoadTime$1,
timeSinceLastClick
}
};
// If the clicked element is an input, add inputMeta
if (target.tagName.toLowerCase() === 'input') {
const input = target;
let label = null;
if (input.id) {
const labelElem = document.querySelector(`label[for='${input.id}']`);
if (labelElem)
label = labelElem.textContent?.trim() || null;
}
clickData.inputMeta = {
type: input.type || null,
name: input.name || null,
placeholder: input.placeholder || null,
label
};
}
// Clean sensitive data on event-specific payload only
return cleanEventData(clickData);
};
// Create throttled click handler
const createClickHandler = (trackingCallback) => {
return throttle((event) => {
const clickData = processClickEvent(event);
if (clickData) {
trackingCallback(clickData);
}
}, RATE_LIMITS.CLICK_THROTTLE_MS);
};
const setupClickCapture = (trackEvent) => {
// Create the click handler
const clickHandler = createClickHandler(trackEvent);
// Add event listener without capture phase to reduce duplications
document.addEventListener('click', clickHandler, {
passive: true
});
// Return cleanup function
return () => {
document.removeEventListener('click', clickHandler);
};
};
// Track form interactions
const formInteractions = new Map();
let pageLoadTime = Date.now();
// Debounce function for form changes
const debounce = (func, delay) => {
let timeoutId = null;
return function (...args) {
if (timeoutId)
clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => func.apply(this, args), delay);
};
};
// Get form element data
const getFormData = (form) => {
Array.from(document.querySelectorAll('form'));
return {
id: form.id || null,
classes: form.className || null,
action: form.action || null,
method: form.method || 'get',
enctype: form.enctype || null,
elementCount: form.elements.length,
selector: getElementSelector(form)
};
};
// Get field data with privacy protection
const getFieldData = (element, form) => {
const input = element;
Array.from(form.elements);
return {
name: input.name || null,
id: input.id || null,
type: input.type || element.tagName.toLowerCase(),
value: redactSensitiveValue(element, input.value || ''),
placeholder: input.placeholder || null,
required: input.required || false,
selector: getElementSelector(element)
};
};
// Analyze form submission
const analyzeSubmission = (form) => {
const elements = Array.from(form.elements);
let filledCount = 0;
let requiredCount = 0;
let errorCount = 0;
// elements is HTMLFormControlsCollection, which doesn't have forEach, so use for loop
for (let i = 0; i < elements.length; i++) {
const input = elements[i];
if (input.required) {
requiredCount++;
}
if (input.value && input.value.trim()) {
filledCount++;
}
if (!input.checkValidity()) {
errorCount++;
}
}
const interaction = formInteractions.get(form);
const timeToSubmit = interaction ? Date.now() - interaction.firstInteraction : 0;
return {
isValid: form.checkValidity(),
errorCount,
filledFieldCount: filledCount,
requiredFieldCount: requiredCount,
timeToSubmit
};
};
// Track form interaction timing
const trackFormInteraction = (form, field) => {
const now = Date.now();
if (!formInteractions.has(form)) {
formInteractions.set(form, {
firstInteraction: now,
lastInteraction: now,
fieldInteractions: new Set()
});
}
const interaction = formInteractions.get(form);
interaction.lastInteraction = now;
if (field) {
interaction.fieldInteractions.add(field);
}
};
// Process form events
const processFormEvent = (event, eventType) => {
const target = event.target;
// Privacy and safety checks
if (!target || shouldIgnoreElement(target)) {
return null;
}
// Find the form
const form = target.closest('form');
if (!form) {
return null;
}
// Add debug logging for form events (if debug is enabled)
if (typeof window !== 'undefined' && window.cruxstackDebug) {
console.log(`Cruxstack: Form ${eventType} event detected`, {
element: target.tagName,
formId: form.id || 'no-id',
fieldName: target.name || 'no-name',
fieldType: target.type || 'no-type'
});
}
const now = Date.now();
const forms = Array.from(document.querySelectorAll('form'));
const formIndex = forms.indexOf(form);
// Track interaction
trackFormInteraction(form, target);
const interaction = formInteractions.get(form);
// Base form data
const formEventData = {
form: getFormData(form),
eventType,
timing: {
timeOnPage: now - pageLoadTime,
timeInForm: interaction ? now - interaction.firstInteraction : null,
timeSinceLastInteraction: interaction && interaction.lastInteraction ?
now - interaction.lastInteraction : null
},
context: {
formIndex,
totalFormsOnPage: forms.length
}
};
// Add field data for field-specific events
if (['change', 'focus', 'blur'].includes(eventType)) {
const formElements = Array.from(form.elements);
const fieldIndex = formElements.indexOf(target);
formEventData.field = getFieldData(target, form);
if (formEventData.context) {
formEventData.context.fieldIndex = fieldIndex >= 0 ? fieldIndex : undefined;
}
}
// Add submission data for submit events
if (eventType === 'submit') {
formEventData.submission = analyzeSubmission(form);
}
// Clean sensitive data on event-specific payload only
return cleanEventData(formEventData);
};
// Create debounced handlers for different event types
const createFormHandlers = (trackingCallback) => {
const debouncedChangeHandler = debounce((event) => {
const formData = processFormEvent(event, 'change');
if (formData) {
trackingCallback(formData);
}
}, RATE_LIMITS.FORM_CHANGE_DEBOUNCE_MS);
const submitHandler = (event) => {
const formData = processFormEvent(event, 'submit');
if (formData) {
trackingCallback(formData);
}
};
const focusHandler = (event) => {
const formData = processFormEvent(event, 'focus');
if (formData) {
trackingCallback(formData);
}
};
const blurHandler = (event) => {
const formData = processFormEvent(event, 'blur');
if (formData) {
trackingCallback(formData);
}
};
return {
change: debouncedChangeHandler,
submit: submitHandler,
focus: focusHandler,
blur: blurHandler
};
};
const setupFormCapture = (trackEvent) => {
// Create the form handlers
const handlers = createFormHandlers(trackEvent);
// Add event listeners without capture phase to reduce duplications
document.addEventListener('submit', handlers.submit);
document.addEventListener('change', handlers.change, { passive: true });
document.addEventListener('focus', handlers.focus, { passive: true });
document.addEventListener('blur', handlers.blur, { passive: true });
// Return cleanup function
return () => {
document.removeEventListener('submit', handlers.submit);
document.removeEventListener('change', handlers.change);
document.removeEventListener('focus', handlers.focus);
document.removeEventListener('blur', handlers.blur);
};
};
// Session tracking
let sessionStartTime = Date.now();
let pageViewCount = 0;
let lastPageTime = null;
let isFirstPageInSession = true;
let maxScrollDepthPercent = 0;
// (Performance, content, and navigation details are handled in the top-level envelope; ev keeps only session/timing/scroll)
// Generate page view data
const generatePageViewData = (triggeredBy, isSPA = false) => {
const now = Date.now();
// Calculate time on previous page
const timeOnPreviousPage = lastPageTime ? now - lastPageTime : null;
// Update tracking variables
pageViewCount++;
const wasFirstPage = isFirstPageInSession;
isFirstPageInSession = false;
// Build page view data
const pageViewData = {
// Only include event-specific portions; common env fields are moved to envelope
session: {
isFirstPageInSession: wasFirstPage,
pageViewCount,
timeOnPreviousPage
},
timing: {
sessionDuration: now - sessionStartTime,
timeToPageView: now - sessionStartTime
},
scrollDepthPercent: Math.min(100, Math.max(0, Math.round(maxScrollDepthPercent)))
};
lastPageTime = now;
// Merge with common properties
// Clean sensitive data (only event-specific payload)
return cleanEventData(pageViewData);
};
// Handle different types of navigation
const handleInitialPageView = () => {
// Setup scroll tracking on initial page load
setupScrollDepthTracking();
return generatePageViewData('initial', false);
};
const handleSPANavigation = (triggeredBy) => {
// Reset scroll depth tracking for new SPA page
resetScrollDepthTracking();
setupScrollDepthTracking();
return generatePageViewData(triggeredBy, true);
};
// Scroll depth tracking utilities
function setupScrollDepthTracking() {
maxScrollDepthPercent = 0;
const onScroll = () => {
try {
const scrollTop = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const docHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.body.clientHeight, document.documentElement.clientHeight);
const maxScrollable = Math.max(1, docHeight - viewportHeight);
const currentDepth = Math.round(((scrollTop + viewportHeight) / maxScrollable) * 100);
if (currentDepth > maxScrollDepthPercent) {
maxScrollDepthPercent = currentDepth;
}
}
catch { }
};
window.addEventListener('scroll', onScroll, { passive: true });
// Store handler for potential future removal if needed (not strictly necessary as we keep it during session)
}
function resetScrollDepthTracking() {
maxScrollDepthPercent = 0;
}
let currentPath = window.location.pathname;
const setupPageViewCapture = (trackEvent) => {
// Track initial page view
const initialPageView = handleInitialPageView();
if (initialPageView) {
trackEvent(initialPageView);
}
// Handle browser back/forward navigation
const handlePopState = () => {
const pageView = handleSPANavigation('popstate');
if (pageView) {
trackEvent(pageView);
}
currentPath = window.location.pathname;
};
// Handle hash changes (for hash-based routing)
const handleHashChange = () => {
const pageView = handleSPANavigation('hashchange');
if (pageView) {
trackEvent(pageView);
}
};
// Override history.pushState for SPA navigation detection
const originalPushState = history.pushState;
const pushStateHandler = function (...args) {
const result = originalPushState.apply(this, args);
// Check if the path actually changed
if (window.location.pathname !== currentPath) {
setTimeout(() => {
const pageView = handleSPANavigation('pushstate');
if (pageView) {
trackEvent(pageView);
}
currentPath = window.location.pathname;
}, 0); // Use setTimeout to ensure DOM updates are complete
}
return result;
};
// Override history.replaceState for SPA navigation detection
const originalReplaceState = history.replaceState;
const replaceStateHandler = function (...args) {
const result = originalReplaceState.apply(this, args);
// Check if the path actually changed
if (window.location.pathname !== currentPath) {
setTimeout(() => {
const pageView = handleSPANavigation('replacestate');
if (pageView) {
trackEvent(pageView);
}
currentPath = window.location.pathname;
}, 0);
}
return result;
};
// Apply history overrides
history.pushState = pushStateHandler;
history.replaceState = replaceStateHandler;
// Add event listeners
window.addEventListener('popstate', handlePopState);
window.addEventListener('hashchange', handleHashChange);
// Return cleanup function
return () => {
// Restore original history methods
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
// Remove event listeners
window.removeEventListener('popstate', handlePopState);
window.removeEventListener('hashchange', handleHashChange);
};
};
// Main autocapture setup function
const setupAutocapture = (trackers, config) => {
// Skip if autocapture is disabled
if (config.autoCapture === false) {
return () => { }; // Return no-op cleanup function
}
const cleanupFunctions = [];
// Set up each capture type
try {
// Page views (should be first to capture initial page load)
const pageViewCleanup = setupPageViewCapture(trackers.trackPageView);
cleanupFunctions.push(pageViewCleanup);
// Click tracking
const clickCleanup = setupClickCapture(trackers.trackClick);
cleanupFunctions.push(clickCleanup);
// Form tracking
const formCleanup = setupFormCapture(trackers.trackForm);
cleanupFunctions.push(formCleanup);
if (config.debugLog) {
console.log('Cruxstack: Autocapture initialized successfully');
}
}
catch (error) {
if (config.debugLog) {
console.error('Cruxstack: Error setting up autocapture:', error);
}
// Clean up any successfully initialized trackers
cleanupFunctions.forEach(cleanup => {
try {
cleanup();
}
catch (cleanupError) {
console.error('Cruxstack: Error during cleanup:', cleanupError);
}
});
return () => { }; // Return no-op if setup failed
}
// Return combined cleanup function
return () => {
cleanupFunctions.forEach(cleanup => {
try {
cleanup();
}
catch (error) {
if (config.debugLog) {
console.error('Cruxstack: Error during autocapture cleanup:', error);
}
}
});
if (config.debugLog) {
console.log('Cruxstack: Autocapture cleaned up');
}
};
};
let sessionManager;
let eventQueue;
let apiClient;
let eventTracker;
let autocaptureCleanup = null;
let globalConfig = null;
// Store event listener references for proper cleanup
let unloadHandler = null;
let onlineHandler = null;
// Generate plain UUID for event IDs
function generateEventId() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function init(config) {
try {
// Prevent multiple initializations
if (eventTracker && globalConfig) {
if (config.debugLog) {
console.warn("Cruxstack: SDK already initialized. Skipping re-initialization.");
}
return;
}
// Validate config
if (!config.clientId) {
throw new Error("Cruxstack: clientId is required. Please provide a valid client identifier.");
}
if (typeof config.clientId !== "string" || config.clientId.trim() === "") {
throw new Error("Cruxstack: clientId must be a non-empty string.");
}
// Store config globally
globalConfig = config;
// Initialize core modules
setDebugLog(config.debugLog || false);
sessionManager = new SessionManager(config);
eventQueue = new EventQueue();
apiClient = new ApiClient(config.clientId, config.customerId, config.customerName, config.debugLog || false);
eventTracker = new EventTracker(apiClient, eventQueue, sessionManager, config.clientId, config.customerId, config.customerName);
// Setup autocapture if enabled
if (config.autoCapture !== false) {
const autocaptureTrackers = {
trackClick: (data) => {
eventTracker.track({
type: "click",
data,
id: generateEventId(),
clientId: config.clientId,
customerId: config.customerId,
customerName: config.customerName,
});
},
trackForm: (data) => {
eventTracker.track({
type: `form_${data.eventType}`,
data,
id: generateEventId(),
clientId: config.clientId,
customerId: config.customerId,
customerName: config.customerName,
});
},
trackPageView: (data) => {
eventTracker.track({
type: "page_view",
data,
id: generateEventId(),
clientId: config.clientId,
customerId: config.customerId,
customerName: config.customerName,
});
},
};
autocaptureCleanup = s