@hubhorizonllc/tracker
Version:
Tracks and analyzes user behavior using Chrome's TextClassifier
397 lines (391 loc) • 15.6 kB
JavaScript
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