From 5340ab9ebbccde48e7f5d68d3734110b6028cf1c Mon Sep 17 00:00:00 2001 From: izume01 Date: Mon, 2 Feb 2026 22:57:40 +0530 Subject: [PATCH 1/2] feat(sdk) : added framework support and sdk support --- app/analytics-worker/analyticsWorker.ts | 25 +- packages/analytics-web/core/config.js | 87 ++++++ packages/analytics-web/core/event.js | 63 +++- packages/analytics-web/core/identity.js | 69 +++++ packages/analytics-web/core/session.js | 65 +++- packages/analytics-web/events/auto.js | 304 +++++++++++++------ packages/analytics-web/events/engagement.js | 227 ++++++++++++++ packages/analytics-web/events/identify.js | 63 ++++ packages/analytics-web/events/navigation.js | 120 ++++++++ packages/analytics-web/events/performance.js | 287 +++++++++++++++++ packages/analytics-web/events/track.js | 33 +- packages/analytics-web/events/trackonView.js | 12 +- packages/analytics-web/frameworks/next.js | 145 +++++++++ packages/analytics-web/frameworks/react.js | 182 +++++++++++ packages/analytics-web/frameworks/vue.js | 160 ++++++++++ packages/analytics-web/index.js | 258 ++++++++++++++-- packages/analytics-web/transport/sender.js | 115 +++++-- test/demo.html | 304 +++++++++++++------ 18 files changed, 2254 insertions(+), 265 deletions(-) create mode 100644 packages/analytics-web/core/config.js create mode 100644 packages/analytics-web/core/identity.js create mode 100644 packages/analytics-web/events/engagement.js create mode 100644 packages/analytics-web/events/identify.js create mode 100644 packages/analytics-web/events/navigation.js create mode 100644 packages/analytics-web/events/performance.js create mode 100644 packages/analytics-web/frameworks/next.js create mode 100644 packages/analytics-web/frameworks/react.js create mode 100644 packages/analytics-web/frameworks/vue.js 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..1495682 100644 --- a/packages/analytics-web/core/session.js +++ b/packages/analytics-web/core/session.js @@ -1,9 +1,68 @@ +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) + */ +export function isNewSession() { + return !sessionStorage.getItem(SESSION_ID_KEY); +} + +/** + * 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} + * + * + * + * ); + * } + */ +export function GlimpseAnalytics(props) { + // This is a template - actual React component needs React import + // Implementation: + /* + 'use client'; + import { usePathname } from 'next/navigation'; + import { useEffect, useRef } from 'react'; + + export function GlimpseAnalytics({ projectId, endpoint, debug = false }) { + const pathname = usePathname(); + const initialized = useRef(false); + const previousPath = useRef(''); + + useEffect(() => { + if (!initialized.current) { + initGlimpse({ projectId, endpoint, debug }); + initialized.current = true; + } + }, [projectId, endpoint, debug]); + + useEffect(() => { + if (pathname && pathname !== previousPath.current) { + trackPageView(pathname); + previousPath.current = pathname; + } + }, [pathname]); + + return null; + } + */ + return null; +} + +/** + * Next.js Pages Router: Use in _app.tsx + * + * @example + * // pages/_app.tsx + * import { useGlimpsePageViews } from 'glimpse-analytics/next'; + * + * export default function App({ Component, pageProps }) { + * useGlimpsePageViews('your-project-id'); + * return ; + * } + */ +export function useGlimpsePageViews(projectId, options = {}) { + // Template for Pages Router hook + // Implementation needs React and next/router: + /* + import { useRouter } from 'next/router'; + import { useEffect, useRef } from 'react'; + + export function useGlimpsePageViews(projectId, options = {}) { + const router = useRouter(); + const initialized = useRef(false); + + useEffect(() => { + if (!initialized.current) { + initGlimpse({ projectId, ...options }); + initialized.current = true; + } + + const handleRouteChange = (url) => { + trackPageView(url); + }; + + router.events.on('routeChangeComplete', handleRouteChange); + return () => { + router.events.off('routeChangeComplete', handleRouteChange); + }; + }, [router.events, projectId, options]); + } + */ +} + +/** + * Script component for Next.js + * Use this if you prefer script tag approach with Next.js + * + * @example + * // app/layout.tsx + * import { GlimpseScript } from 'glimpse-analytics/next'; + * + * export default function RootLayout({ children }) { + * return ( + * + * + * {children} + * + * + * + * ); + * } + */ +export function GlimpseScript({ projectId, endpoint, src }) { + // Template - needs next/script: + /* + import Script from 'next/script'; + + export function GlimpseScript({ projectId, endpoint, src = '/glimpse.js' }) { + return ( + + */ +export function useGlimpse() { + return analytics; +} + +/** + * Directive for tracking element visibility + * + * @example + * + */ +export const vTrackView = { + mounted(el, binding) { + if (!isBrowser()) return; + + const { event, props = {}, threshold = 0.5 } = binding.value || {}; + if (!event) { + console.warn('v-track-view: event name required'); + return; + } + + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + track(event, { + ...props, + elementId: el.id || undefined, + }); + observer.disconnect(); + } + }); + }, { threshold }); + + observer.observe(el); + el._glimpseObserver = observer; + }, + + unmounted(el) { + if (el._glimpseObserver) { + el._glimpseObserver.disconnect(); + } + } +}; + +/** + * Directive for tracking clicks + * + * @example + * + */ +export const vTrackClick = { + mounted(el, binding) { + const { event, props = {} } = binding.value || {}; + if (!event) { + console.warn('v-track-click: event name required'); + return; + } + + const handler = () => { + track(event, { + ...props, + elementId: el.id || undefined, + elementText: el.textContent?.substring(0, 50), + }); + }; + + el.addEventListener('click', handler); + el._glimpseClickHandler = handler; + }, + + unmounted(el) { + if (el._glimpseClickHandler) { + el.removeEventListener('click', el._glimpseClickHandler); + } + } +}; + +// Re-export +export { analytics, track, trackPageView, initGlimpse }; +export default GlimpsePlugin; diff --git a/packages/analytics-web/index.js b/packages/analytics-web/index.js index 5eb5e70..cbad25d 100644 --- a/packages/analytics-web/index.js +++ b/packages/analytics-web/index.js @@ -1,24 +1,244 @@ -import { track } from "./events/track.js"; -import trackOnView from "./events/trackonView.js"; -const script = document.currentScript - || document.querySelector('script[data-project-id][src*="analytics-web/index.js"]') - || document.querySelector('script[data-project-id]'); +/** + * Glimpse Analytics SDK v2.0 + * Lightweight, privacy-focused analytics for the web + * + * Works with: Vanilla JS, React, Next.js, Vue, Svelte, and any framework + */ -const projectIdAttr = script ? script.getAttribute('data-project-id') : undefined; -const projectId = projectIdAttr && projectIdAttr !== 'undefined' ? projectIdAttr : undefined; +// SSR safety check +const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'; -window.GlimpseTracker = { - projectId, +// Initialize in browser only +if (isBrowser) { + initBrowser(); +} + +async function initBrowser() { + // Dynamic imports for browser-only modules + const [ + { track, trackNow }, + { identify, setTraits, reset, alias }, + { trackPageView }, + { default: trackOnView }, + { getAnonymousId, getUserId }, + { getSessionId, getSessionDuration }, + { flush } + ] = await Promise.all([ + import('./events/track.js'), + import('./events/identify.js'), + import('./events/navigation.js'), + import('./events/trackonView.js'), + import('./core/identity.js'), + import('./core/session.js'), + import('./transport/sender.js') + ]); + + // Get configuration from script tag (for vanilla JS usage) + const script = document.currentScript + || document.querySelector('script[data-project-id][src*="analytics"]') + || document.querySelector('script[data-project-id]'); + + const projectId = script?.getAttribute('data-project-id') || undefined; + const endpoint = script?.getAttribute('data-endpoint') || undefined; + const autoTrack = script?.getAttribute('data-auto-track') !== 'false'; + const debug = script?.getAttribute('data-debug') === 'true'; + + // Initialize global tracker + window.GlimpseTracker = { + // Config + projectId, + endpoint, + debug, + + // Initialization (for frameworks) + init: (options = {}) => { + if (options.projectId) { + window.GlimpseTracker.projectId = options.projectId; + } + if (options.endpoint) { + window.GlimpseTracker.endpoint = options.endpoint; + } + if (options.debug !== undefined) { + window.GlimpseTracker.debug = options.debug; + } + + // Load auto-tracking if enabled and projectId is set + if (options.autoTrack !== false && window.GlimpseTracker.projectId) { + import('./events/auto.js').catch(console.error); + } + + return window.GlimpseTracker; + }, + + // Core tracking + track, + trackNow, + trackPageView, + trackOnView, + + // Identity + identify, + setTraits, + reset, + alias, + + // Utilities + getAnonymousId, + getUserId, + getSessionId, + getSessionDuration, + flush, + + // Version + version: '2.0.0' + }; + + // Dispatch event so frameworks know SDK is ready + window.dispatchEvent(new CustomEvent('glimpse:ready', { detail: window.GlimpseTracker })); + + // Auto-tracking (only if projectId is set via script tag) + if (projectId && autoTrack) { + import('./events/auto.js') + .then(() => { + if (debug) { + console.info('GlimpseTracker: initialized with auto-tracking'); + } + }) + .catch((err) => { + console.warn('GlimpseTracker: auto-tracking failed', err); + }); + } else if (!projectId && script) { + console.warn('GlimpseTracker: missing data-project-id. Use init({ projectId: "..." }) or add it to the script tag.'); + } +} + +const noop = () => {}; + +function getTracker() { + return isBrowser ? window.GlimpseTracker : null; +} + +export function track(name, properties) { + getTracker()?.track?.(name, properties); +} + +export function trackNow(name, properties) { + getTracker()?.trackNow?.(name, properties); +} + +export function trackPageView(path, properties) { + getTracker()?.trackPageView?.(path, properties); +} + +export function trackOnView(selector, eventName, properties, options) { + return getTracker()?.trackOnView?.(selector, eventName, properties, options) || noop; +} + +export function identify(userId, traits) { + getTracker()?.identify?.(userId, traits); +} + +export function setTraits(traits) { + getTracker()?.setTraits?.(traits); +} + +export function reset() { + getTracker()?.reset?.(); +} + +export function alias(newUserId) { + getTracker()?.alias?.(newUserId); +} + +export function flush() { + getTracker()?.flush?.(); +} + +export function getAnonymousId() { + return getTracker()?.getAnonymousId?.() || null; +} + +export function getUserId() { + return getTracker()?.getUserId?.() || null; +} + +export function getSessionId() { + return getTracker()?.getSessionId?.() || null; +} + +export function getSessionDuration() { + return getTracker()?.getSessionDuration?.() || 0; +} + +/** + * Initialize Glimpse SDK + * For frameworks: call in useEffect (React) or onMounted (Vue) + * + * @param {Object} options + * @param {string} options.projectId - Your project ID (required) + * @param {string} options.endpoint - API endpoint (optional) + * @param {boolean} options.autoTrack - Enable auto-tracking (default: true) + * @param {boolean} options.debug - Enable debug logging (default: false) + * @returns {Promise} The tracker instance or null on server + * + * @example + * // React + * useEffect(() => { + * initGlimpse({ projectId: 'my-project' }); + * }, []); + * + * // Vue + * onMounted(() => { + * initGlimpse({ projectId: 'my-project' }); + * }); + */ +export async function initGlimpse(options = {}) { + if (!isBrowser) { + return null; + } + + // Wait for SDK to be ready if not yet initialized + if (!window.GlimpseTracker) { + await new Promise(resolve => { + if (window.GlimpseTracker) { + resolve(); + } else { + window.addEventListener('glimpse:ready', resolve, { once: true }); + // Timeout fallback + setTimeout(resolve, 1000); + } + }); + } + + return window.GlimpseTracker?.init?.(options) || null; +} + +/** + * Get the tracker instance (SSR-safe) + */ +export { getTracker }; + +/** + * Check if running in browser + */ +export { isBrowser }; + +// Default export +export default { track, + trackNow, + trackPageView, trackOnView, + identify, + setTraits, + reset, + alias, + flush, + getAnonymousId, + getUserId, + getSessionId, + getSessionDuration, + initGlimpse, + getTracker, + isBrowser }; - -if (projectId) { - import('./events/auto.js') - .then(() => console.info('GlimpseTracker: auto-tracking enabled')) - .catch((err) => { - console.warn('GlimpseTracker: auto-tracking failed to load', err); - }); -} else { - console.warn('GlimpseTracker: missing data-project-id on script tag. Auto-tracking disabled.'); -} \ No newline at end of file diff --git a/packages/analytics-web/transport/sender.js b/packages/analytics-web/transport/sender.js index fa96b65..8fbf0c5 100644 --- a/packages/analytics-web/transport/sender.js +++ b/packages/analytics-web/transport/sender.js @@ -1,55 +1,82 @@ -const ENDPOINT = `http://localhost:3000/event`; +/** + * Event transport layer - batches and sends events to the server + */ +const DEFAULT_ENDPOINT = 'http://localhost:3000/event'; const BUFFER_SIZE = 10; -const FLUSH_INTERVAL = 5000; +const FLUSH_INTERVAL = 5000; const MAX_RETRIES = 3; - +const RETRY_DELAY = 1000; let eventBuffer = []; -let flushTimer = null +let flushTimer = null; let isFlushing = false; -function send(events) { - events.map(event => { +/** + * Get the configured endpoint + */ +function getEndpoint() { + return window.GlimpseTracker?.endpoint || DEFAULT_ENDPOINT; +} + +/** + * Send events to the server + */ +async function send(events) { + const endpoint = getEndpoint(); + + for (const event of events) { const payload = JSON.stringify(event); let attempts = 0; - - function attemptSend() { + + const attemptSend = async () => { attempts++; - // Use fetch with no credentials for cross-origin requests - fetch(ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: payload, - keepalive: true, - credentials: 'omit', - mode: 'cors' - }).catch((err) => { + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + keepalive: true, + credentials: 'omit', + mode: 'cors' + }); + + if (!response.ok && attempts < MAX_RETRIES) { + setTimeout(attemptSend, RETRY_DELAY * attempts); + } + } catch (err) { if (attempts < MAX_RETRIES) { - setTimeout(attemptSend, 1000 * attempts); + setTimeout(attemptSend, RETRY_DELAY * attempts); } else { - console.error('Failed to send analytics events:', err); + console.error('GlimpseTracker: Failed to send event', err); } - }); - } + } + }; + attemptSend(); - }) + } } +/** + * Flush the event buffer + */ function flushBuffer() { - if (isFlushing || eventBuffer.length === 0) return; - + if (isFlushing || eventBuffer.length === 0) return; + isFlushing = true; - const eventsToSend = [...eventBuffer] + const eventsToSend = [...eventBuffer]; eventBuffer = []; - + send(eventsToSend); isFlushing = false; } +/** + * Enqueue an event for sending + */ function enqueueEvent(event) { eventBuffer.push(event); - + if (eventBuffer.length >= BUFFER_SIZE) { flushBuffer(); if (flushTimer) { @@ -65,5 +92,37 @@ function enqueueEvent(event) { } } +/** + * Force flush all pending events (use before page unload) + */ +function flush() { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + + if (eventBuffer.length > 0) { + // Use sendBeacon for reliable delivery + const endpoint = getEndpoint(); + const events = [...eventBuffer]; + eventBuffer = []; + + for (const event of events) { + if (navigator.sendBeacon) { + navigator.sendBeacon(endpoint, JSON.stringify(event)); + } + } + } +} + +// Flush on page hide +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + flush(); + } +}); + +window.addEventListener('pagehide', flush); -export default enqueueEvent \ No newline at end of file +export default enqueueEvent; +export { flush, flushBuffer }; diff --git a/test/demo.html b/test/demo.html index 06ca761..ff818b1 100644 --- a/test/demo.html +++ b/test/demo.html @@ -5,139 +5,213 @@ Glimpse Tracker Demo -

🔍 Glimpse Tracker Demo

+

🔍 Glimpse Analytics Demo

- Loads the client script with data-project-id and sends sample events to - http://localhost:3000/event. + SDK v2.0 with identity tracking, Web Vitals, engagement metrics, and more. + Events are sent to http://localhost:3000/event.

📊 Status

-
Waiting for tracker...
+
Initializing tracker...
+
+
+
-
+
Session ID
+
+
+
-
+
Anonymous ID
+
+
+
-
+
User ID
+
+
- + +
+

👤 Identity Tracking

+

Track users across sessions with persistent IDs:

+
+ + + +
+
+ + +
+

identify(userId, traits) links anonymous activity to a user

+
+
-

🖱️ Custom Events (Manual Tracking)

-

These use tracker.track(eventName, properties) directly:

+

🖱️ Custom Events

+

Track custom events with track(name, properties):

- +

→ Fires custom events with your own properties

+
-

🔘 Button Click Tracking (data-track attribute)

-

Buttons with data-track attribute are auto-tracked:

+

🔘 Auto Button Tracking

+

Buttons with data-track are auto-tracked:

- -

→ Auto-tracks via data-track attribute

+ +

→ Add data-track attribute to any button

+
-

📝 Form Submission Tracking

-

Form submissions are auto-tracked:

+

📝 Form Tracking

+

Form submissions and field focus are auto-tracked:

- - + + +
-

→ Auto-tracks form submissions with action URL

+

→ Tracks form submissions, field focus (without PII)

+
-

🔗 Outbound Link Tracking

-

External link clicks are auto-tracked:

+

🔗 Link & Download Tracking

+

External links and file downloads are auto-tracked:

GitHub (External) | Google (External) | - Internal Link (Not Tracked) -

→ Auto-tracks clicks on links to different domains

+ Download PDF | + Download Excel +

→ Auto-tracks outbound clicks and file downloads

+
-

📥 File Download Tracking

-

Downloads of common file types are auto-tracked:

- Download PDF | - Download Excel | - Download ZIP -

→ Auto-tracks: pdf, doc, docx, xls, xlsx, ppt, pptx, zip, rar, 7z, exe, dmg

+

⚠️ Error Tracking

+

JavaScript errors and promise rejections:

+ + +

→ Auto-captures errors with stack traces

+
-

⚠️ Error Tracking

-

JavaScript errors and unhandled promises are auto-tracked:

- - -

→ Auto-tracks window errors and unhandled promise rejections

+

⚡ Performance & Web Vitals

+

Automatically tracks page load performance:

+
    +
  • Page Load - DNS, TTFB, DOM ready, load complete
  • +
  • LCP - Largest Contentful Paint
  • +
  • FCP - First Contentful Paint
  • +
  • FID - First Input Delay
  • +
  • CLS - Cumulative Layout Shift
  • +
  • TTFB - Time to First Byte
  • +
+

→ Web Vitals tracked automatically with good/needs-improvement/poor ratings

+
+ + +
+

📈 Engagement Tracking

+

Measures user engagement and interaction quality:

+
    +
  • Active Time - Time user is actually engaged
  • +
  • Scroll Depth - Maximum scroll position reached
  • +
  • Interactions - Clicks, keypresses, touches
  • +
  • Bounce Detection - Left without engaging
  • +
  • Rage Clicks - Frustration indicator
  • +
  • Page Exit - Comprehensive exit data
  • +
+

→ Tracks 30-second heartbeats for long sessions

+
-

👁️ Visibility Tracking

-

Page visibility changes are auto-tracked. Try switching tabs!

-

→ Auto-tracks "Page Hidden" and "Page Visible" events

+

🎬 Media Tracking

+

Track video/audio engagement with data-track:

+ +

→ Tracks play, pause, progress milestones (25/50/75%), complete

+
-

📜 Scroll Depth Tracking

-

Scroll down to see depth tracking in action:

-

→ Auto-tracks at milestones: 10%, 25%, 50%, 75%, 90%, 100%

+

📜 Scroll & Visibility Tracking

+

Scroll down to see element visibility tracking:

+

→ Tracks scroll milestones: 25%, 50%, 75%, 90%, 100%

-

Keep scrolling to trigger scroll depth events...

+

Keep scrolling to trigger visibility events...

-
+

💰 Pricing Section (Auto)

-

This section is tracked via data-track-view attribute

+

Tracked via data-track-view

-
-

🎯 Custom Tracked Section

-

This is tracked via tracker.trackOnView('#custom-section-1', 'Viewed Testimonials')

+
+

🎯 Testimonials Section

+

Tracked via trackOnView('#custom-section-1', 'Viewed Testimonials')

-
+

✨ Features Section (Auto)

-

This section is tracked via data-track-view attribute

+

Tracked via data-track-view

-
+

📦 Product Demo Section

-

This is tracked with custom properties via trackOnView

+

Tracked with 75% visibility threshold

-
-

🚀 Call to Action (Auto)

-

Track when users scroll to your CTA!

+
+

🚀 Final CTA

+

You've reached the bottom!

-

🎉 100% scroll depth!

+

🎉 100% scroll depth!

-

🚪 Session End Tracking

-

Closing or navigating away from this page triggers "Session End".

-

→ Auto-tracks via beforeunload event

+

🚪 Session End

+

Closing or navigating away tracks "Session End" with full engagement data.

+

→ Uses sendBeacon for reliable delivery on unload

- - + + From 85b07b7bc6b61dfac981dc549aa94e7023478c40 Mon Sep 17 00:00:00 2001 From: izume01 Date: Tue, 3 Feb 2026 02:08:57 +0530 Subject: [PATCH 2/2] feat(session): enhance session check logic for timeout handling feat(init): improve GlimpseTracker initialization with timeout handling fix(sender): optimize event sending with concurrent attempts and better retry logic --- packages/analytics-web/core/session.js | 8 ++++++-- packages/analytics-web/index.js | 12 ++++++++---- packages/analytics-web/transport/sender.js | 20 ++++++++++---------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/analytics-web/core/session.js b/packages/analytics-web/core/session.js index 1495682..eb36e20 100644 --- a/packages/analytics-web/core/session.js +++ b/packages/analytics-web/core/session.js @@ -31,10 +31,14 @@ export function getSessionId() { } /** - * Check if this is a new session (first page view) + * Check if this is a new session (first page view or after timeout) */ export function isNewSession() { - return !sessionStorage.getItem(SESSION_ID_KEY); + 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); } /** diff --git a/packages/analytics-web/index.js b/packages/analytics-web/index.js index cbad25d..03baf1b 100644 --- a/packages/analytics-web/index.js +++ b/packages/analytics-web/index.js @@ -199,13 +199,17 @@ export async function initGlimpse(options = {}) { // Wait for SDK to be ready if not yet initialized if (!window.GlimpseTracker) { - await new Promise(resolve => { + await new Promise((resolve, reject) => { if (window.GlimpseTracker) { resolve(); } else { - window.addEventListener('glimpse:ready', resolve, { once: true }); - // Timeout fallback - setTimeout(resolve, 1000); + const timeout = setTimeout(() => { + reject(new Error('GlimpseTracker initialization timeout')); + }, 5000); + window.addEventListener('glimpse:ready', () => { + clearTimeout(timeout); + resolve(); + }, { once: true }); } }); } diff --git a/packages/analytics-web/transport/sender.js b/packages/analytics-web/transport/sender.js index 8fbf0c5..ad6d993 100644 --- a/packages/analytics-web/transport/sender.js +++ b/packages/analytics-web/transport/sender.js @@ -25,12 +25,10 @@ function getEndpoint() { async function send(events) { const endpoint = getEndpoint(); - for (const event of events) { + await Promise.allSettled(events.map(event => { const payload = JSON.stringify(event); - let attempts = 0; - const attemptSend = async () => { - attempts++; + const attemptSend = async (attempts = 0) => { try { const response = await fetch(endpoint, { method: 'POST', @@ -41,20 +39,22 @@ async function send(events) { mode: 'cors' }); - if (!response.ok && attempts < MAX_RETRIES) { - setTimeout(attemptSend, RETRY_DELAY * attempts); + if (!response.ok && attempts < MAX_RETRIES - 1) { + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (attempts + 1))); + return attemptSend(attempts + 1); } } catch (err) { - if (attempts < MAX_RETRIES) { - setTimeout(attemptSend, RETRY_DELAY * attempts); + if (attempts < MAX_RETRIES - 1) { + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (attempts + 1))); + return attemptSend(attempts + 1); } else { console.error('GlimpseTracker: Failed to send event', err); } } }; - attemptSend(); - } + return attemptSend(); + })); } /**