diff --git a/app/analytics-worker/analyticsWorker.ts b/app/analytics-worker/analyticsWorker.ts index ef6af09..71d9ebe 100644 --- a/app/analytics-worker/analyticsWorker.ts +++ b/app/analytics-worker/analyticsWorker.ts @@ -1,7 +1,17 @@ import { Worker, Queue } from "bullmq"; -import { redis } from "@glimpse/db/redis"; import { prisma } from "@glimpse/db/client"; + +/** + * What to precompute for analytics: + * + * AnalyticsSession + * DailyEventAggregate + * DailyPageAnalytics + * DailyUserAnalytics + * AnalyticsCheckpoint + */ + const redisPort = process.env.REDIS_PORT ? Number(process.env.REDIS_PORT) : 6379; @@ -23,7 +33,18 @@ const analyticsWorker = new Worker( switch (job.name) { case "analytics-job": const { data } = job; - // Process the analytics data + + // AnalyticsSession computation + + const analyticsSession = { + projectId: data.projectId, + sessionId: data.sessionId, + + + createdAt : new Date(), + updatedAt : new Date(), + } + break; default: console.warn(`Unknown job type: ${job.name}`); diff --git a/packages/analytics-web/core/config.js b/packages/analytics-web/core/config.js new file mode 100644 index 0000000..0cdd00b --- /dev/null +++ b/packages/analytics-web/core/config.js @@ -0,0 +1,87 @@ +/** + * SDK Configuration + * Supports both script tag attributes and programmatic initialization + */ + +let config = { + projectId: undefined, + endpoint: 'http://localhost:3000/event', + autoTrack: true, + debug: false, + trackPageViews: true, + trackWebVitals: true, + trackEngagement: true, + trackErrors: true, + sessionTimeout: 30 * 60 * 1000, // 30 minutes +}; + +let initialized = false; + +/** + * Check if running in browser + */ +export function isBrowser() { + return typeof window !== 'undefined' && typeof document !== 'undefined'; +} + +/** + * Get current config + */ +export function getConfig() { + return { ...config }; +} + +/** + * Initialize the SDK programmatically + * Use this for React, Vue, Next.js, etc. + */ +export function init(options = {}) { + if (!isBrowser()) { + console.warn('GlimpseTracker: Cannot initialize on server. Use in useEffect/onMounted.'); + return false; + } + + if (initialized && !options.force) { + if (config.debug) console.info('GlimpseTracker: Already initialized'); + return true; + } + + config = { + ...config, + ...options, + }; + + if (!config.projectId) { + console.warn('GlimpseTracker: projectId is required'); + return false; + } + + initialized = true; + + // Update global tracker + if (window.GlimpseTracker) { + window.GlimpseTracker.projectId = config.projectId; + window.GlimpseTracker.endpoint = config.endpoint; + window.GlimpseTracker.debug = config.debug; + } + + if (config.debug) { + console.info('GlimpseTracker: Initialized', config); + } + + return true; +} + +/** + * Check if SDK is initialized + */ +export function isInitialized() { + return initialized; +} + +/** + * Reset initialization (for testing) + */ +export function resetInit() { + initialized = false; +} diff --git a/packages/analytics-web/core/event.js b/packages/analytics-web/core/event.js index 90ad3c8..bd3f2ea 100644 --- a/packages/analytics-web/core/event.js +++ b/packages/analytics-web/core/event.js @@ -1,43 +1,78 @@ +import { getAnonymousId, getUserId, getTraits } from './identity.js'; + // Track previous path within the session -let prevPath = sessionStorage.getItem('analytics_prev_path') || undefined; +let prevPath = sessionStorage.getItem('glimpse_prev_path') || undefined; -export function createEvent(projectId, sessionId, name, properties) { - const currentPath = location.pathname + location.search + location.hash; +/** + * Create a fully-formed event object matching the schema + */ +export function createEvent(projectId, sessionId, name, properties = {}) { + const currentPath = location.pathname + location.search; const evt = { projectId, event: name, timestamp: Date.now(), + + // Identity sessionId, + anonymousId: getAnonymousId(), + userId: getUserId(), + traits: getTraits(), + + // Event data properties: properties || {}, + + // Context context: { + // Page info url: window.location.href, - referrer: document.referrer || undefined, path: currentPath, + hash: location.hash || undefined, title: document.title || undefined, + referrer: document.referrer || undefined, previousPath: prevPath, - - viewport: `${window.innerWidth}x${window.innerHeight}`, + + // Device & viewport + viewport: { + width: window.innerWidth, + height: window.innerHeight + }, screen: { width: window.screen.width, height: window.screen.height, - colorDepth: window.screen.colorDepth + colorDepth: window.screen.colorDepth, + pixelRatio: window.devicePixelRatio || 1 }, - + + // Browser info userAgent: navigator.userAgent, language: navigator.language, + languages: navigator.languages ? [...navigator.languages] : [navigator.language], timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - - connection: navigator.connection && { + cookiesEnabled: navigator.cookieEnabled, + + // Connection info + connection: navigator.connection ? { effectiveType: navigator.connection.effectiveType, - saveData: navigator.connection.saveData, - }, + downlink: navigator.connection.downlink, + rtt: navigator.connection.rtt, + saveData: navigator.connection.saveData + } : undefined, + + // Touch capability + touchPoints: navigator.maxTouchPoints || 0, + + // Platform hints + platform: navigator.userAgentData ? { + mobile: navigator.userAgentData.mobile, + platform: navigator.userAgentData.platform + } : undefined } }; prevPath = currentPath; - sessionStorage.setItem('analytics_prev_path', currentPath); + sessionStorage.setItem('glimpse_prev_path', currentPath); return evt; } - diff --git a/packages/analytics-web/core/identity.js b/packages/analytics-web/core/identity.js new file mode 100644 index 0000000..a79c4a3 --- /dev/null +++ b/packages/analytics-web/core/identity.js @@ -0,0 +1,69 @@ +const ANON_ID_KEY = 'glimpse_anonymous_id'; +const USER_ID_KEY = 'glimpse_user_id'; +const TRAITS_KEY = 'glimpse_traits'; + +/** + * Get or create a persistent anonymous ID + */ +export function getAnonymousId() { + let anonId = localStorage.getItem(ANON_ID_KEY); + if (!anonId) { + anonId = `anon_${crypto.randomUUID ? crypto.randomUUID() : generateUUID()}`; + localStorage.setItem(ANON_ID_KEY, anonId); + } + return anonId; +} + +/** + * Get the current user ID (if identified) + */ +export function getUserId() { + return localStorage.getItem(USER_ID_KEY) || undefined; +} + +/** + * Get stored user traits + */ +export function getTraits() { + try { + const stored = localStorage.getItem(TRAITS_KEY); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } +} + +/** + * Set user identity and traits + */ +export function setIdentity(userId, traits = {}) { + if (userId) { + localStorage.setItem(USER_ID_KEY, userId); + } + + const existingTraits = getTraits(); + const mergedTraits = { ...existingTraits, ...traits }; + localStorage.setItem(TRAITS_KEY, JSON.stringify(mergedTraits)); + + return { userId, traits: mergedTraits }; +} + +/** + * Reset identity (logout) + */ +export function resetIdentity() { + localStorage.removeItem(USER_ID_KEY); + localStorage.removeItem(TRAITS_KEY); + // Keep anonymous ID - regenerate on next page load if needed +} + +/** + * Fallback UUID generator for older browsers + */ +function generateUUID() { + 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); + }); +} diff --git a/packages/analytics-web/core/session.js b/packages/analytics-web/core/session.js index 6f5a621..eb36e20 100644 --- a/packages/analytics-web/core/session.js +++ b/packages/analytics-web/core/session.js @@ -1,9 +1,72 @@ +const SESSION_ID_KEY = 'glimpse_session_id'; +const SESSION_START_KEY = 'glimpse_session_start'; +const SESSION_LAST_ACTIVE_KEY = 'glimpse_session_last_active'; +const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes + +/** + * Get or create session ID + * Sessions expire after 30 minutes of inactivity + */ export function getSessionId() { - let sid = sessionStorage.getItem('analytics_session_id'); + const now = Date.now(); + let sid = sessionStorage.getItem(SESSION_ID_KEY); + const lastActive = parseInt(sessionStorage.getItem(SESSION_LAST_ACTIVE_KEY) || '0', 10); + + // Check if session expired + if (sid && lastActive && (now - lastActive > SESSION_TIMEOUT)) { + // Session expired, create new one + sid = null; + } + if (!sid) { - sid = `sess_${Math.random().toString(36).substring(2, 15)}`; - sessionStorage.setItem('analytics_session_id', sid); + sid = `sess_${crypto.randomUUID ? crypto.randomUUID().slice(0, 12) : Math.random().toString(36).substring(2, 15)}`; + sessionStorage.setItem(SESSION_ID_KEY, sid); + sessionStorage.setItem(SESSION_START_KEY, now.toString()); } + + // Update last active time + sessionStorage.setItem(SESSION_LAST_ACTIVE_KEY, now.toString()); + return sid; } +/** + * Check if this is a new session (first page view or after timeout) + */ +export function isNewSession() { + const sid = sessionStorage.getItem(SESSION_ID_KEY); + if (!sid) return true; + + const lastActive = parseInt(sessionStorage.getItem(SESSION_LAST_ACTIVE_KEY) || '0', 10); + return lastActive && (Date.now() - lastActive > SESSION_TIMEOUT); +} + +/** + * Get session start timestamp + */ +export function getSessionStart() { + return parseInt(sessionStorage.getItem(SESSION_START_KEY) || Date.now().toString(), 10); +} + +/** + * Get session duration in milliseconds + */ +export function getSessionDuration() { + const start = getSessionStart(); + return Date.now() - start; +} + +/** + * Track page view count in session + */ +const PAGE_VIEW_COUNT_KEY = 'glimpse_page_view_count'; + +export function incrementPageViewCount() { + const count = getPageViewCount() + 1; + sessionStorage.setItem(PAGE_VIEW_COUNT_KEY, count.toString()); + return count; +} + +export function getPageViewCount() { + return parseInt(sessionStorage.getItem(PAGE_VIEW_COUNT_KEY) || '0', 10); +} diff --git a/packages/analytics-web/events/auto.js b/packages/analytics-web/events/auto.js index ffc13f1..1f2e344 100644 --- a/packages/analytics-web/events/auto.js +++ b/packages/analytics-web/events/auto.js @@ -1,154 +1,264 @@ -import { track } from "./track.js"; +import { track, trackNow } from "./track.js"; +import { initNavigationTracking } from "./navigation.js"; +import { initPerformanceTracking } from "./performance.js"; +import { initEngagementTracking } from "./engagement.js"; +import { isNewSession, getSessionDuration } from "../core/session.js"; -// Auto Track Page View -track('Page View' , { - "path" : location.pathname, - "referrer": document.referrer || undefined, -}) +/** + * Auto-tracking module - initializes all automatic tracking + */ -// Auto Track Session Start -track('Session Start'); +// ═══════════════════════════════════════════════════════════════════ +// SESSION TRACKING +// ═══════════════════════════════════════════════════════════════════ -// Scroll Depth Tracking -const scrollMilestones = [10, 25, 50, 75, 90, 100]; -const reachedMilestones = new Set(); +if (isNewSession()) { + track('Session Start', { + referrer: document.referrer || undefined, + landingPage: location.pathname + }); +} -function getScrollDepth() { - const scrollTop = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0; - const windowHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight || 0; +// ═══════════════════════════════════════════════════════════════════ +// CORE TRACKING +// ═══════════════════════════════════════════════════════════════════ - const docHeight = Math.max( - document.body.scrollHeight, document.documentElement.scrollHeight, - document.body.offsetHeight, document.documentElement.offsetHeight, - document.body.clientHeight, document.documentElement.clientHeight - ) +initNavigationTracking(); +initPerformanceTracking(); +initEngagementTracking(); - const scrollDepth = (scrollTop + windowHeight) / docHeight; +// ═══════════════════════════════════════════════════════════════════ +// SCROLL DEPTH MILESTONES +// ═══════════════════════════════════════════════════════════════════ - const scrollDepthPercent = Math.floor(scrollDepth * 100); +const scrollMilestones = [25, 50, 75, 90, 100]; +const reachedMilestones = new Set(); - return Math.min(scrollDepthPercent, 100); +function getScrollDepth() { + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const windowHeight = window.innerHeight; + const docHeight = Math.max( + document.body.scrollHeight, + document.documentElement.scrollHeight + ); + return Math.min(Math.round(((scrollTop + windowHeight) / docHeight) * 100), 100); } window.addEventListener('scroll', () => { - const percentage = getScrollDepth(); + const depth = getScrollDepth(); for (const milestone of scrollMilestones) { - if (percentage >= milestone && !reachedMilestones.has(milestone)) { + if (depth >= milestone && !reachedMilestones.has(milestone)) { reachedMilestones.add(milestone); - track('Scroll Depth', { depth: milestone }); + track('Scroll Milestone', { depth: milestone }); } } -}) +}, { passive: true }); -// Element Visibility Tracking (scroll to specific elements) -const viewedElements = new Set(); +// ═══════════════════════════════════════════════════════════════════ +// ELEMENT VISIBILITY TRACKING +// ═══════════════════════════════════════════════════════════════════ + +const viewedElements = new WeakSet(); function setupElementVisibilityTracking() { - const elementsToTrack = document.querySelectorAll('[data-track-view]'); - - if (elementsToTrack.length === 0) return; + const elements = document.querySelectorAll('[data-track-view]'); + if (elements.length === 0) return; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { - if (entry.isIntersecting) { + if (entry.isIntersecting && !viewedElements.has(entry.target)) { + viewedElements.add(entry.target); const el = entry.target; - const elementId = el.id || el.getAttribute('data-track-view') || el.tagName; - - if (!viewedElements.has(el)) { - viewedElements.add(el); - track('Element Viewed', { - elementId: elementId, - elementName: el.getAttribute('data-track-name') || elementId, - elementTag: el.tagName.toLowerCase() - }); - } + track('Element Viewed', { + elementId: el.id || el.getAttribute('data-track-view'), + elementName: el.getAttribute('data-track-name'), + elementTag: el.tagName.toLowerCase() + }); } }); - }, { - threshold: 0.5 // Element is 50% visible - }); + }, { threshold: 0.5 }); - elementsToTrack.forEach(el => observer.observe(el)); + elements.forEach(el => observer.observe(el)); } -// Run after DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', setupElementVisibilityTracking); } else { setupElementVisibilityTracking(); } -// Auto Track Visibility Change +// ═══════════════════════════════════════════════════════════════════ +// VISIBILITY CHANGE +// ═══════════════════════════════════════════════════════════════════ + document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - track('Page Hidden'); - } else if (document.visibilityState === 'visible') { - track('Page Visible'); - } + track(document.visibilityState === 'hidden' ? 'Page Hidden' : 'Page Visible', { + sessionDuration: getSessionDuration() + }); }); -// Auto Track Out bound Link Clicks +// ═══════════════════════════════════════════════════════════════════ +// LINK TRACKING +// ═══════════════════════════════════════════════════════════════════ + document.addEventListener('click', (event) => { - const target = event.target; - if (target.tagName === 'A' && target.href) { - const linkHost = new URL(target.href).host; - if (linkHost !== window.location.host) { - track('Outbound Link Click', { url: target.href }); - } + const link = event.target.closest('a[href]'); + if (!link) return; + + const url = new URL(link.href, location.origin); + + // Outbound link + if (url.host !== location.host) { + track('Outbound Click', { + url: link.href, + text: link.innerText?.substring(0, 100), + destination: url.host + }); } -}); + + // File download + const downloadExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'rar', 'exe', 'dmg', 'apk']; + const ext = url.pathname.split('.').pop()?.toLowerCase(); + if (ext && downloadExtensions.includes(ext)) { + track('File Download', { + url: link.href, + fileType: ext, + fileName: url.pathname.split('/').pop() + }); + } +}, { passive: true }); + +// ═══════════════════════════════════════════════════════════════════ +// FORM TRACKING +// ═══════════════════════════════════════════════════════════════════ + +document.addEventListener('submit', (event) => { + const form = event.target; + if (form.tagName !== 'FORM') return; + + track('Form Submit', { + formId: form.id || undefined, + formName: form.name || undefined, + formAction: form.action || undefined, + formMethod: form.method?.toUpperCase() || 'GET' + }); +}, { passive: true }); + +// Form field focus tracking (anonymized) +document.addEventListener('focusin', (event) => { + const input = event.target; + if (!input.matches('input, textarea, select')) return; + + const form = input.closest('form'); + track('Form Field Focus', { + fieldType: input.type || input.tagName.toLowerCase(), + fieldName: input.name || undefined, + formId: form?.id || undefined + }); +}, { passive: true }); + +// ═══════════════════════════════════════════════════════════════════ +// BUTTON / CTA TRACKING +// ═══════════════════════════════════════════════════════════════════ + +document.addEventListener('click', (event) => { + const button = event.target.closest('button[data-track], [role="button"][data-track]'); + if (!button) return; + + track('Button Click', { + buttonId: button.id || undefined, + buttonName: button.getAttribute('data-track-name') || button.innerText?.substring(0, 50), + buttonType: button.type || undefined + }); +}, { passive: true }); + +// ═══════════════════════════════════════════════════════════════════ +// ERROR TRACKING +// ═══════════════════════════════════════════════════════════════════ -// Auto Track Errors window.addEventListener('error', (event) => { track('JavaScript Error', { message: event.message, source: event.filename, - lineno: event.lineno, - colno: event.colno, - error: event.error ? event.error.stack : undefined + line: event.lineno, + column: event.colno, + stack: event.error?.stack?.substring(0, 500) }); }); window.addEventListener('unhandledrejection', (event) => { - track('Unhandled Promise Rejection', { - reason: event.reason ? (event.reason.stack || event.reason) : 'unknown' + track('Promise Rejection', { + reason: event.reason?.message || String(event.reason)?.substring(0, 200) }); }); -// File Download Tracking -document.addEventListener('click', (event) => { - const target = event.target; - if (target.tagName === 'A' && target.href) { - const url = new URL(target.href); - const fileExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'rar', '7z', 'exe', 'dmg']; - const path = url.pathname.toLowerCase(); - for (const ext of fileExtensions) { - if (path.endsWith(`.${ext}`)) { - track('File Download', { url: target.href, fileType: ext }); - break; +// ═══════════════════════════════════════════════════════════════════ +// MEDIA TRACKING +// ═══════════════════════════════════════════════════════════════════ + +function setupMediaTracking() { + const mediaElements = document.querySelectorAll('video[data-track], audio[data-track]'); + + mediaElements.forEach(media => { + const mediaId = media.id || media.getAttribute('data-track') || media.src?.split('/').pop(); + const mediaType = media.tagName.toLowerCase(); + + media.addEventListener('play', () => { + track('Media Play', { mediaId, mediaType, currentTime: Math.round(media.currentTime) }); + }); + + media.addEventListener('pause', () => { + track('Media Pause', { mediaId, mediaType, currentTime: Math.round(media.currentTime) }); + }); + + media.addEventListener('ended', () => { + track('Media Complete', { mediaId, mediaType, duration: Math.round(media.duration) }); + }); + + // Track progress milestones + const progressMilestones = [25, 50, 75]; + const reachedProgress = new Set(); + + media.addEventListener('timeupdate', () => { + const progress = Math.round((media.currentTime / media.duration) * 100); + for (const milestone of progressMilestones) { + if (progress >= milestone && !reachedProgress.has(milestone)) { + reachedProgress.add(milestone); + track('Media Progress', { mediaId, mediaType, progress: milestone }); + } } - } - } -}); + }); + }); +} -// Auto Track Session End on unload -window.addEventListener('beforeunload', () => { - track('Session End'); -}); +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setupMediaTracking); +} else { + setupMediaTracking(); +} -// Form Interaction Tracking -document.addEventListener('submit', (event) => { - const target = event.target; - if (target.tagName === 'FORM') { - track('Form Submitted', { formAction: target.action || undefined }); +// ═══════════════════════════════════════════════════════════════════ +// COPY TRACKING +// ═══════════════════════════════════════════════════════════════════ + +document.addEventListener('copy', () => { + const selection = document.getSelection(); + const text = selection?.toString()?.substring(0, 100); + + if (text && text.length > 0) { + track('Text Copied', { + textLength: selection?.toString()?.length, + textPreview: text + }); } }); -// Button / CTA Click Tracking -document.querySelectorAll('button[data-track]').forEach(btn => { - btn.addEventListener('click', () => { - const btnName = btn.getAttribute('data-track-name') || btn.innerText || 'Unnamed Button'; - track('Button Click', { buttonName: btnName }); - }); -}) +// ═══════════════════════════════════════════════════════════════════ +// SESSION END +// ═══════════════════════════════════════════════════════════════════ +window.addEventListener('pagehide', () => { + trackNow('Session End', { + duration: getSessionDuration() + }); +}); diff --git a/packages/analytics-web/events/engagement.js b/packages/analytics-web/events/engagement.js new file mode 100644 index 0000000..57c25d4 --- /dev/null +++ b/packages/analytics-web/events/engagement.js @@ -0,0 +1,227 @@ +import { track, trackNow } from "./track.js"; +import { getSessionDuration, getPageViewCount } from "../core/session.js"; + +/** + * Engagement tracking - time on page, interactions, active time + */ + +let pageStartTime = Date.now(); +let activeTime = 0; +let lastActiveTimestamp = Date.now(); +let isActive = true; +let interactionCount = 0; +let maxScrollDepth = 0; +let hasInteracted = false; + +// Engagement thresholds +const ENGAGED_TIME_THRESHOLD = 10000; // 10 seconds +const ENGAGED_SCROLL_THRESHOLD = 25; // 25% scroll + +/** + * Track when user becomes active/inactive + */ +function updateActiveTime() { + const now = Date.now(); + if (isActive) { + activeTime += now - lastActiveTimestamp; + } + lastActiveTimestamp = now; +} + +/** + * Handle visibility change + */ +function handleVisibilityChange() { + updateActiveTime(); + isActive = document.visibilityState === 'visible'; + lastActiveTimestamp = Date.now(); +} + +/** + * Track user interactions + */ +function handleInteraction() { + if (!hasInteracted) { + hasInteracted = true; + track('First Interaction', { + timeToInteract: Date.now() - pageStartTime + }); + } + interactionCount++; + updateActiveTime(); + isActive = true; + lastActiveTimestamp = Date.now(); +} + +/** + * Track scroll depth + */ +function handleScroll() { + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const windowHeight = window.innerHeight; + const docHeight = Math.max( + document.body.scrollHeight, + document.documentElement.scrollHeight + ); + + const scrollDepth = Math.min( + Math.round(((scrollTop + windowHeight) / docHeight) * 100), + 100 + ); + + if (scrollDepth > maxScrollDepth) { + maxScrollDepth = scrollDepth; + } +} + +/** + * Determine if user is engaged + */ +function isEngaged() { + updateActiveTime(); + return activeTime >= ENGAGED_TIME_THRESHOLD || maxScrollDepth >= ENGAGED_SCROLL_THRESHOLD; +} + +/** + * Determine if this is a bounce (left without meaningful engagement) + */ +function isBounce() { + return getPageViewCount() <= 1 && !isEngaged(); +} + +/** + * Track page exit with engagement data + */ +function trackPageExit() { + updateActiveTime(); + + trackNow('Page Exit', { + timeOnPage: Date.now() - pageStartTime, + activeTime: activeTime, + interactionCount: interactionCount, + maxScrollDepth: maxScrollDepth, + engaged: isEngaged(), + bounce: isBounce(), + sessionDuration: getSessionDuration(), + pageViewsInSession: getPageViewCount() + }); +} + +/** + * Track engagement heartbeat (for long sessions) + */ +let heartbeatInterval = null; +const HEARTBEAT_INTERVAL = 30000; // 30 seconds + +function startHeartbeat() { + if (heartbeatInterval) return; + + heartbeatInterval = setInterval(() => { + if (isActive) { + updateActiveTime(); + track('Heartbeat', { + activeTime, + interactionCount, + maxScrollDepth, + pageTime: Date.now() - pageStartTime + }); + } + }, HEARTBEAT_INTERVAL); +} + +function stopHeartbeat() { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } +} + +/** + * Track rage clicks (frustration indicator) + */ +let clickTimestamps = []; +const RAGE_CLICK_THRESHOLD = 3; +const RAGE_CLICK_WINDOW = 500; // 500ms + +function handleClick(event) { + const now = Date.now(); + clickTimestamps.push(now); + + // Keep only recent clicks + clickTimestamps = clickTimestamps.filter(t => now - t < RAGE_CLICK_WINDOW); + + if (clickTimestamps.length >= RAGE_CLICK_THRESHOLD) { + track('Rage Click', { + clickCount: clickTimestamps.length, + element: event.target?.tagName?.toLowerCase(), + elementId: event.target?.id || undefined, + elementClass: event.target?.className || undefined + }); + clickTimestamps = []; + } +} + +/** + * Track dead clicks (clicks that don't do anything) + */ +function handleDeadClick(event) { + const target = event.target; + + // Check if click target looks interactive but might not work + const isInteractive = target.matches('a, button, input, select, textarea, [role="button"], [onclick]'); + const hasHref = target.tagName === 'A' && target.getAttribute('href'); + const hasClickHandler = target.onclick || target.getAttribute('onclick'); + + if (!isInteractive && !hasHref && !hasClickHandler) { + // This might be a dead click on something that looks clickable + const computedStyle = window.getComputedStyle(target); + if (computedStyle.cursor === 'pointer') { + track('Dead Click', { + element: target.tagName.toLowerCase(), + elementId: target.id || undefined, + text: target.innerText?.substring(0, 50) || undefined + }); + } + } +} + +/** + * Initialize engagement tracking + */ +export function initEngagementTracking() { + // Visibility tracking + document.addEventListener('visibilitychange', handleVisibilityChange); + + // Interaction tracking + document.addEventListener('click', handleInteraction, { passive: true }); + document.addEventListener('keydown', handleInteraction, { passive: true }); + document.addEventListener('touchstart', handleInteraction, { passive: true }); + + // Scroll tracking + window.addEventListener('scroll', handleScroll, { passive: true }); + + // Rage click detection + document.addEventListener('click', handleClick, { passive: true }); + + // Dead click detection + document.addEventListener('click', handleDeadClick, { passive: true }); + + // Page exit tracking + window.addEventListener('pagehide', trackPageExit); + window.addEventListener('beforeunload', trackPageExit); + + // Start heartbeat for long sessions + startHeartbeat(); + + // Stop heartbeat when page hidden + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + stopHeartbeat(); + } else { + startHeartbeat(); + } + }); +} + +// Export for manual tracking +export { isEngaged, isBounce, maxScrollDepth, activeTime, interactionCount }; diff --git a/packages/analytics-web/events/identify.js b/packages/analytics-web/events/identify.js new file mode 100644 index 0000000..0a38de9 --- /dev/null +++ b/packages/analytics-web/events/identify.js @@ -0,0 +1,63 @@ +import { setIdentity, resetIdentity, getAnonymousId, getUserId, getTraits } from "../core/identity.js"; +import { track } from "./track.js"; + +/** + * Identify a user with a unique ID and optional traits + * @param {string} userId - Unique user identifier + * @param {object} traits - User traits (name, email, plan, etc.) + */ +export function identify(userId, traits = {}) { + const previousUserId = getUserId(); + const result = setIdentity(userId, traits); + + // Track the identify event + track('User Identified', { + userId: result.userId, + traits: result.traits, + previousUserId: previousUserId || undefined, + isNewIdentity: !previousUserId + }); + + return result; +} + +/** + * Add or update user traits without changing userId + * @param {object} traits - User traits to merge + */ +export function setTraits(traits = {}) { + const userId = getUserId(); + return setIdentity(userId, traits); +} + +/** + * Reset user identity (for logout) + */ +export function reset() { + const previousUserId = getUserId(); + const anonymousId = getAnonymousId(); + + resetIdentity(); + + track('User Reset', { + previousUserId, + anonymousId + }); +} + +/** + * Alias: Link anonymous ID to a user ID (for signup flows) + * @param {string} newUserId - The new user ID to link + */ +export function alias(newUserId) { + const anonymousId = getAnonymousId(); + const previousUserId = getUserId(); + + track('User Alias', { + anonymousId, + previousUserId, + newUserId + }); + + return identify(newUserId); +} diff --git a/packages/analytics-web/events/navigation.js b/packages/analytics-web/events/navigation.js new file mode 100644 index 0000000..ed8b261 --- /dev/null +++ b/packages/analytics-web/events/navigation.js @@ -0,0 +1,120 @@ +import { track } from "./track.js"; +import { incrementPageViewCount } from "../core/session.js"; + +/** + * SPA Navigation tracking - works with React Router, Next.js, Vue Router, etc. + */ + +let currentPath = location.pathname + location.search; +let pageViewStartTime = Date.now(); + +/** + * Track a page view + */ +function trackPageView(path, options = {}) { + const timeOnPreviousPage = Date.now() - pageViewStartTime; + pageViewStartTime = Date.now(); + + const pageViewCount = incrementPageViewCount(); + + track('Page View', { + path: path || location.pathname, + search: location.search || undefined, + hash: location.hash || undefined, + title: document.title, + referrer: options.referrer || document.referrer || undefined, + previousPath: options.previousPath || currentPath, + timeOnPreviousPage: pageViewCount > 1 ? timeOnPreviousPage : undefined, + pageViewNumber: pageViewCount, + navigationType: options.navigationType || 'spa' + }); + + currentPath = location.pathname + location.search; +} + +/** + * Hook into History API for SPA navigation + */ +function setupHistoryTracking() { + // Store original methods + const originalPushState = history.pushState; + const originalReplaceState = history.replaceState; + + // Override pushState + history.pushState = function(...args) { + const result = originalPushState.apply(this, args); + handleNavigation('pushState'); + return result; + }; + + // Override replaceState + history.replaceState = function(...args) { + const result = originalReplaceState.apply(this, args); + handleNavigation('replaceState'); + return result; + }; + + // Handle popstate (back/forward) + window.addEventListener('popstate', () => { + handleNavigation('popstate'); + }); +} + +/** + * Handle navigation events + */ +function handleNavigation(type) { + const newPath = location.pathname + location.search; + + // Only track if path actually changed + if (newPath !== currentPath) { + const previousPath = currentPath; + + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => { + trackPageView(newPath, { + previousPath, + navigationType: type + }); + }); + } +} + +/** + * Track hash changes (for hash-based routing) + */ +function setupHashTracking() { + window.addEventListener('hashchange', (event) => { + track('Hash Change', { + oldHash: new URL(event.oldURL).hash, + newHash: location.hash, + path: location.pathname + }); + }); +} + +/** + * Initialize navigation tracking + */ +export function initNavigationTracking() { + // Track initial page view + incrementPageViewCount(); + track('Page View', { + path: location.pathname, + search: location.search || undefined, + hash: location.hash || undefined, + title: document.title, + referrer: document.referrer || undefined, + pageViewNumber: 1, + navigationType: 'initial' + }); + + // Setup SPA tracking + setupHistoryTracking(); + setupHashTracking(); +} + +/** + * Manual page view tracking (for frameworks that need it) + */ +export { trackPageView }; diff --git a/packages/analytics-web/events/performance.js b/packages/analytics-web/events/performance.js new file mode 100644 index 0000000..82de4c2 --- /dev/null +++ b/packages/analytics-web/events/performance.js @@ -0,0 +1,287 @@ +import { track } from "./track.js"; + +/** + * Track Core Web Vitals and page load performance + */ + +let pageLoadTracked = false; +let webVitalsTracked = { + LCP: false, + FID: false, + CLS: false, + FCP: false, + TTFB: false, + INP: false +}; + +/** + * Track page load timing + */ +export function trackPageLoad() { + if (pageLoadTracked) return; + + // Wait for load event if not fired yet + if (document.readyState !== 'complete') { + window.addEventListener('load', () => setTimeout(trackPageLoad, 0)); + return; + } + + pageLoadTracked = true; + + const perf = performance.getEntriesByType('navigation')[0]; + if (!perf) return; + + track('Page Load', { + // Navigation timing + dns: Math.round(perf.domainLookupEnd - perf.domainLookupStart), + tcp: Math.round(perf.connectEnd - perf.connectStart), + ssl: perf.secureConnectionStart > 0 ? Math.round(perf.connectEnd - perf.secureConnectionStart) : 0, + ttfb: Math.round(perf.responseStart - perf.requestStart), + download: Math.round(perf.responseEnd - perf.responseStart), + domInteractive: Math.round(perf.domInteractive), + domContentLoaded: Math.round(perf.domContentLoadedEventEnd), + domComplete: Math.round(perf.domComplete), + loadComplete: Math.round(perf.loadEventEnd), + + // Transfer info + transferSize: perf.transferSize, + encodedBodySize: perf.encodedBodySize, + decodedBodySize: perf.decodedBodySize, + + // Navigation type + navigationType: perf.type, // 'navigate', 'reload', 'back_forward', 'prerender' + redirectCount: perf.redirectCount + }); +} + +/** + * Track Largest Contentful Paint (LCP) + */ +export function trackLCP() { + if (webVitalsTracked.LCP || !('PerformanceObserver' in window)) return; + + try { + const observer = new PerformanceObserver((list) => { + const entries = list.getEntries(); + const lastEntry = entries[entries.length - 1]; + + if (lastEntry && !webVitalsTracked.LCP) { + webVitalsTracked.LCP = true; + track('Web Vital', { + metric: 'LCP', + value: Math.round(lastEntry.startTime), + rating: lastEntry.startTime <= 2500 ? 'good' : lastEntry.startTime <= 4000 ? 'needs-improvement' : 'poor', + element: lastEntry.element?.tagName?.toLowerCase() + }); + observer.disconnect(); + } + }); + + observer.observe({ type: 'largest-contentful-paint', buffered: true }); + } catch (e) { + // LCP not supported + } +} + +/** + * Track First Input Delay (FID) / Interaction to Next Paint (INP) + */ +export function trackFID() { + if (webVitalsTracked.FID || !('PerformanceObserver' in window)) return; + + try { + const observer = new PerformanceObserver((list) => { + const entries = list.getEntries(); + const firstInput = entries[0]; + + if (firstInput && !webVitalsTracked.FID) { + webVitalsTracked.FID = true; + const delay = firstInput.processingStart - firstInput.startTime; + track('Web Vital', { + metric: 'FID', + value: Math.round(delay), + rating: delay <= 100 ? 'good' : delay <= 300 ? 'needs-improvement' : 'poor', + eventType: firstInput.name + }); + observer.disconnect(); + } + }); + + observer.observe({ type: 'first-input', buffered: true }); + } catch (e) { + // FID not supported + } +} + +/** + * Track Cumulative Layout Shift (CLS) + */ +export function trackCLS() { + if (webVitalsTracked.CLS || !('PerformanceObserver' in window)) return; + + let clsValue = 0; + let sessionValue = 0; + let sessionEntries = []; + + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (!entry.hadRecentInput) { + const firstSessionEntry = sessionEntries[0]; + const lastSessionEntry = sessionEntries[sessionEntries.length - 1]; + + // Session window: 5 seconds max, 1 second gap + if ( + sessionValue && + entry.startTime - lastSessionEntry.startTime < 1000 && + entry.startTime - firstSessionEntry.startTime < 5000 + ) { + sessionValue += entry.value; + sessionEntries.push(entry); + } else { + sessionValue = entry.value; + sessionEntries = [entry]; + } + + if (sessionValue > clsValue) { + clsValue = sessionValue; + } + } + } + }); + + observer.observe({ type: 'layout-shift', buffered: true }); + + // Report CLS on page hide + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden' && !webVitalsTracked.CLS && clsValue > 0) { + webVitalsTracked.CLS = true; + track('Web Vital', { + metric: 'CLS', + value: clsValue.toFixed(4), + rating: clsValue <= 0.1 ? 'good' : clsValue <= 0.25 ? 'needs-improvement' : 'poor' + }); + observer.disconnect(); + } + }); + } catch (e) { + // CLS not supported + } +} + +/** + * Track First Contentful Paint (FCP) + */ +export function trackFCP() { + if (webVitalsTracked.FCP || !('PerformanceObserver' in window)) return; + + try { + const observer = new PerformanceObserver((list) => { + const entries = list.getEntries(); + const fcp = entries.find(e => e.name === 'first-contentful-paint'); + + if (fcp && !webVitalsTracked.FCP) { + webVitalsTracked.FCP = true; + track('Web Vital', { + metric: 'FCP', + value: Math.round(fcp.startTime), + rating: fcp.startTime <= 1800 ? 'good' : fcp.startTime <= 3000 ? 'needs-improvement' : 'poor' + }); + observer.disconnect(); + } + }); + + observer.observe({ type: 'paint', buffered: true }); + } catch (e) { + // FCP not supported + } +} + +/** + * Track Time to First Byte (TTFB) + */ +export function trackTTFB() { + if (webVitalsTracked.TTFB) return; + + const perf = performance.getEntriesByType('navigation')[0]; + if (!perf) { + // Wait and retry + setTimeout(trackTTFB, 100); + return; + } + + webVitalsTracked.TTFB = true; + const ttfb = perf.responseStart - perf.requestStart; + + track('Web Vital', { + metric: 'TTFB', + value: Math.round(ttfb), + rating: ttfb <= 800 ? 'good' : ttfb <= 1800 ? 'needs-improvement' : 'poor' + }); +} + +/** + * Track resource loading performance + */ +export function trackResources() { + if (!('PerformanceObserver' in window)) return; + + // Aggregate resource stats instead of individual resources + setTimeout(() => { + const resources = performance.getEntriesByType('resource'); + + const stats = { + total: resources.length, + scripts: 0, + styles: 0, + images: 0, + fonts: 0, + other: 0, + totalTransferSize: 0, + slowResources: [] + }; + + resources.forEach(r => { + stats.totalTransferSize += r.transferSize || 0; + + if (r.initiatorType === 'script') stats.scripts++; + else if (r.initiatorType === 'link' || r.initiatorType === 'css') stats.styles++; + else if (r.initiatorType === 'img') stats.images++; + else if (r.initiatorType === 'font' || r.name.match(/\.(woff2?|ttf|eot|otf)$/i)) stats.fonts++; + else stats.other++; + + // Track slow resources (> 1s) + if (r.duration > 1000 && stats.slowResources.length < 5) { + stats.slowResources.push({ + name: r.name.split('/').pop()?.substring(0, 50), + type: r.initiatorType, + duration: Math.round(r.duration) + }); + } + }); + + track('Resource Stats', stats); + }, 3000); // Wait for resources to load +} + +/** + * Initialize all performance tracking + */ +export function initPerformanceTracking() { + // Track page load after it completes + if (document.readyState === 'complete') { + setTimeout(trackPageLoad, 0); + } else { + window.addEventListener('load', () => setTimeout(trackPageLoad, 0)); + } + + // Track Web Vitals + trackLCP(); + trackFCP(); + trackFID(); + trackCLS(); + trackTTFB(); + + // Track resource stats + trackResources(); +} diff --git a/packages/analytics-web/events/track.js b/packages/analytics-web/events/track.js index f5e411d..49e0e31 100644 --- a/packages/analytics-web/events/track.js +++ b/packages/analytics-web/events/track.js @@ -2,8 +2,13 @@ import enqueueEvent from "../transport/sender.js"; import { getSessionId } from "../core/session.js"; import { createEvent } from "../core/event.js"; +/** + * Track a custom event + * @param {string} name - Event name + * @param {object} properties - Event properties + */ export function track(name, properties) { - const projectId = window.GlimpseTracker && window.GlimpseTracker.projectId; + const projectId = window.GlimpseTracker?.projectId; if (!projectId) { console.warn("GlimpseTracker: missing projectId. Set data-project-id on the script tag."); return; @@ -14,3 +19,29 @@ export function track(name, properties) { enqueueEvent(event); } +/** + * Track an event immediately (bypass buffer) - useful for unload events + * @param {string} name - Event name + * @param {object} properties - Event properties + */ +export function trackNow(name, properties) { + const projectId = window.GlimpseTracker?.projectId; + if (!projectId) return; + + const sessionId = getSessionId(); + const event = createEvent(projectId, sessionId, name, properties); + + // Use sendBeacon for reliable delivery on page unload + const endpoint = window.GlimpseTracker?.endpoint || 'http://localhost:3000/event'; + const payload = JSON.stringify(event); + + if (navigator.sendBeacon) { + navigator.sendBeacon(endpoint, payload); + } else { + // Fallback to sync XHR + const xhr = new XMLHttpRequest(); + xhr.open('POST', endpoint, false); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(payload); + } +} diff --git a/packages/analytics-web/events/trackonView.js b/packages/analytics-web/events/trackonView.js index 30edda4..d0baef0 100644 --- a/packages/analytics-web/events/trackonView.js +++ b/packages/analytics-web/events/trackonView.js @@ -1,5 +1,15 @@ +import { track } from "./track.js"; + const customViewedElements = new WeakSet(); +/** + * Track when elements become visible in the viewport + * @param {string|Element} selector - CSS selector or element + * @param {string} eventName - Event name to track + * @param {object} properties - Additional properties + * @param {object} options - { threshold: 0.5, once: true } + * @returns {function} - Cleanup function to stop observing + */ function trackOnView(selector, eventName, properties = {}, options = {}) { const threshold = options.threshold ?? 0.5; const once = options.once ?? true; @@ -10,7 +20,7 @@ function trackOnView(selector, eventName, properties = {}, options = {}) { if (elements.length === 0) { console.warn(`GlimpseTracker: No elements found for selector "${selector}"`); - return; + return () => {}; } const observer = new IntersectionObserver((entries) => { diff --git a/packages/analytics-web/frameworks/next.js b/packages/analytics-web/frameworks/next.js new file mode 100644 index 0000000..45ea576 --- /dev/null +++ b/packages/analytics-web/frameworks/next.js @@ -0,0 +1,145 @@ +/** + * Next.js integration for Glimpse Analytics + * + * Supports both Pages Router and App Router + */ + +'use client'; + +import { isBrowser } from '../core/config.js'; +import { analytics, initGlimpse, track, trackPageView } from './react.js'; + +/** + * Next.js App Router: Analytics component + * + * @example + * // app/layout.tsx + * import { GlimpseAnalytics } from 'glimpse-analytics/next'; + * + * export default function RootLayout({ children }) { + * return ( + * + *
+ * {children} + *