UNPKG

@hubhorizonllc/tracker

Version:

Tracks and analyzes user behavior using Chrome's TextClassifier

397 lines (391 loc) 15.6 kB
import { useRef, useState, useEffect, useCallback } from 'react'; function generateSummary(interactions, startTime) { if (interactions.length === 0) { return "No user interactions recorded."; } const duration = Math.round((Date.now() - startTime) / 1000); const clickCount = interactions.filter((i) => i.type === "click").length; const scrollCount = interactions.filter((i) => i.type === "scroll").length; const inputCount = interactions.filter((i) => i.type === "input").length; const errorCount = interactions.filter((i) => i.type === "error").length; // Group clicks by target const clickTargets = interactions .filter((i) => i.type === "click") .reduce((acc, curr) => { const target = curr.target || "unknown"; acc[target] = (acc[target] || 0) + 1; return acc; }, {}); // Get max scroll percentage const maxScroll = interactions .filter((i) => i.type === "scroll" && i.value) .reduce((max, curr) => { const percentage = Number.parseInt(curr.value?.replace("%", "") || "0"); return Math.max(max, percentage); }, 0); // Check for rapid interactions (potential frustration indicator) const interactionTimes = interactions.map((i) => i.timestamp); let rapidInteractions = 0; for (let i = 1; i < interactionTimes.length; i++) { if (interactionTimes[i] - interactionTimes[i - 1] < 300) { // Less than 300ms between interactions rapidInteractions++; } } // Check for repeated clicks on the same element (potential frustration indicator) const repeatedClicks = Object.values(clickTargets).filter((count) => count > 2).length; // Generate summary text let summary = `User session lasted ${duration} seconds with ${interactions.length} total interactions: `; summary += `${clickCount} clicks, ${scrollCount} scroll events, ${inputCount} input events, and ${errorCount} errors. `; if (clickCount > 0) { const clickTargetsList = Object.entries(clickTargets) .map(([target, count]) => `${target} (${count}x)`) .join(", "); summary += `User clicked on: ${clickTargetsList}. `; } if (scrollCount > 0) { summary += `User scrolled to ${maxScroll}% of the page. `; } if (rapidInteractions > 3) { summary += `User had ${rapidInteractions} rapid interactions, which may indicate frustration. `; } if (repeatedClicks > 0) { summary += `User repeatedly clicked on ${repeatedClicks} element(s), which may indicate frustration or confusion. `; } if (errorCount > 0) { summary += `User encountered ${errorCount} errors, which may have affected their experience. `; } // Analyze the interaction pattern const firstInteraction = interactions[0]; const lastInteraction = interactions[interactions.length - 1]; if (firstInteraction && lastInteraction) { const sessionDuration = lastInteraction.timestamp - firstInteraction.timestamp; if (sessionDuration < 10000 && interactions.length > 5) { summary += "User had a short but intense interaction session. "; } else if (sessionDuration > 60000) { summary += "User had a prolonged interaction session. "; } } return summary; } async function classifyText(text) { try { // Access the Chrome TextClassifier API const textClassifier = navigator.textClassifier; if (!textClassifier) { throw new Error("TextClassifier API not available"); } // Define classification options const options = { categories: [ { name: "frustrated", description: "User is showing signs of frustration or annoyance" }, { name: "satisfied", description: "User appears to be satisfied with their experience" }, { name: "confused", description: "User seems confused or lost" }, { name: "engaged", description: "User is actively engaged with the content" }, { name: "disinterested", description: "User shows little interest in the content" }, ], }; // Classify the text const result = await textClassifier.classify(text, options); if (!result || !result.categories || result.categories.length === 0) { throw new Error("No classification results returned"); } // Find the highest scoring category const topCategory = result.categories.reduce((prev, current) => (current.score > prev.score ? current : prev), { name: "unknown", score: 0, }); return { sentiment: topCategory.name, score: topCategory.score, rawResponse: result, }; } catch (error) { console.error("Classification error:", error); return { sentiment: "unknown", score: 0, rawResponse: { error: String(error) }, }; } } class UserBehaviorTracker { constructor(options = {}) { this.interactions = []; this.timeoutId = null; this.callback = null; this.isSupported = false; this.handleClick = (event) => { const target = event.target; const targetInfo = this.getElementInfo(target); this.recordInteraction({ type: "click", target: targetInfo, timestamp: Date.now(), metadata: { x: event.clientX, y: event.clientY, path: this.getElementPath(target), }, }); }; this.handleScroll = () => { // Throttle scroll events if (!this.lastScrollTime || Date.now() - this.lastScrollTime > 1000) { this.lastScrollTime = Date.now(); const scrollPercentage = Math.round((window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100); this.recordInteraction({ type: "scroll", value: `${scrollPercentage}%`, timestamp: Date.now(), metadata: { scrollY: window.scrollY, viewportHeight: window.innerHeight, documentHeight: document.documentElement.scrollHeight, }, }); } }; this.lastScrollTime = null; this.handleInput = (event) => { const target = event.target; const targetInfo = this.getElementInfo(target); // Don't record actual input values for privacy, just the fact that input occurred this.recordInteraction({ type: "input", target: targetInfo, timestamp: Date.now(), metadata: { inputType: target.type || "text", hasValue: !!target.value, path: this.getElementPath(target), }, }); }; this.handleError = (event) => { this.recordInteraction({ type: "error", value: event.message, timestamp: Date.now(), metadata: { filename: event.filename, lineno: event.lineno, colno: event.colno, }, }); }; this.options = { debug: options.debug ?? false, autoSuggest: options.autoSuggest ?? false, minInteractions: options.minInteractions ?? 5, maxCollectionTime: options.maxCollectionTime ?? 30000, // 30 seconds promptTemplate: options.promptTemplate ?? "Analyze this user behavior and classify the sentiment: {{summary}}", }; this.startTime = Date.now(); this.checkSupport(); } async checkSupport() { try { // Check if TextClassifier API is available this.isSupported = "textClassifier" in navigator && typeof navigator.textClassifier?.classify === "function"; if (this.options.debug) { console.log(`TextClassifier API ${this.isSupported ? "is" : "is not"} supported`); } } catch (error) { this.isSupported = false; if (this.options.debug) { console.error("Error checking TextClassifier support:", error); } } } start(callback) { if (!this.isSupported) { console.warn("TextClassifier API is not supported in this browser. SDK will collect data but classification will not work."); } this.callback = callback; this.attachEventListeners(); // Set timeout for max collection time this.timeoutId = window.setTimeout(() => { this.processInteractions(); }, this.options.maxCollectionTime); if (this.options.debug) { console.log("UserBehaviorTracker started"); } } stop() { this.detachEventListeners(); if (this.timeoutId) { window.clearTimeout(this.timeoutId); this.timeoutId = null; } if (this.options.debug) { console.log("UserBehaviorTracker stopped"); } } attachEventListeners() { // Click events document.addEventListener("click", this.handleClick); // Scroll events window.addEventListener("scroll", this.handleScroll); // Input events document.addEventListener("input", this.handleInput); // Error events window.addEventListener("error", this.handleError); } detachEventListeners() { document.removeEventListener("click", this.handleClick); window.removeEventListener("scroll", this.handleScroll); document.removeEventListener("input", this.handleInput); window.removeEventListener("error", this.handleError); } getElementInfo(element) { // Try to get the most descriptive identifier for the element const id = element.id ? `#${element.id}` : ""; const classes = element.className && typeof element.className === "string" ? `.${element.className.split(" ").join(".")}` : ""; const tagName = element.tagName.toLowerCase(); const text = element.textContent?.trim().substring(0, 20); return (id || (element.getAttribute("data-testid") ? `[data-testid="${element.getAttribute("data-testid")}"]` : "") || (text ? `${tagName}:contains("${text}")` : tagName + classes)); } getElementPath(element, maxDepth = 3) { const path = []; let current = element; let depth = 0; while (current && depth < maxDepth) { path.unshift(this.getElementInfo(current)); current = current.parentElement; depth++; } return path.join(" > "); } recordInteraction(interaction) { this.interactions.push(interaction); if (this.options.debug) { console.log("Recorded interaction:", interaction); } // Check if we've reached the minimum number of interactions if (this.interactions.length >= this.options.minInteractions) { this.processInteractions(); } } async processInteractions() { if (this.interactions.length === 0) return; // Generate summary text const summary = generateSummary(this.interactions, this.startTime); if (this.options.debug) { console.log("Generated summary:", summary); } // Only attempt classification if the API is supported if (this.isSupported) { try { // Apply prompt template const prompt = this.options.promptTemplate.replace("{{summary}}", summary); // Classify the text const result = await classifyText(prompt); if (this.callback) { this.callback(result); } if (this.options.debug) { console.log("Classification result:", result); } if (this.options.autoSuggest && result.score > 0.7 && result.sentiment === "frustrated") { console.warn("User appears frustrated. Consider improving the UX in this area."); } } catch (error) { if (this.options.debug) { console.error("Classification error:", error); } } } else if (this.callback) { // If not supported, return a default result this.callback({ sentiment: "unknown", score: 0, rawResponse: { error: "TextClassifier API not supported" }, }); } // Reset interactions this.interactions = []; this.startTime = Date.now(); // Reset timeout if (this.timeoutId) { window.clearTimeout(this.timeoutId); this.timeoutId = window.setTimeout(() => { this.processInteractions(); }, this.options.maxCollectionTime); } } addCustomInteraction(type, data) { this.recordInteraction({ type: type, value: JSON.stringify(data), timestamp: Date.now(), metadata: { custom: true, data }, }); } } // "use client" directive preserved for Next.js // This directive is needed for Next.js /** * React hook for tracking user behavior with @hubhorizonllc/tracker */ function useUserBehaviorTracker(options = {}) { const { autoStart = true, ...trackerOptions } = options; const trackerRef = useRef(null); const [result, setResult] = useState(null); const [isTracking, setIsTracking] = useState(false); // Initialize the tracker on first render useEffect(() => { trackerRef.current = new UserBehaviorTracker(trackerOptions); return () => { // Clean up on unmount if (trackerRef.current && isTracking) { trackerRef.current.stop(); } }; }, []); // Empty dependency array ensures this only runs once // Start tracking if autoStart is true useEffect(() => { if (autoStart && trackerRef.current && !isTracking) { start(); } }, [autoStart]); // Start tracking function const start = useCallback(() => { if (!trackerRef.current) return; trackerRef.current.start((classificationResult) => { setResult(classificationResult); }); setIsTracking(true); }, []); // Stop tracking function const stop = useCallback(() => { if (!trackerRef.current) return; trackerRef.current.stop(); setIsTracking(false); }, []); // Add custom interaction function const addCustomInteraction = useCallback((type, data) => { if (!trackerRef.current) return; trackerRef.current.addCustomInteraction(type, data); }, []); return { result, isTracking, start, stop, addCustomInteraction, }; } export { UserBehaviorTracker, useUserBehaviorTracker }; //# sourceMappingURL=index.esm.js.map