| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| export interface TrackingScriptOptions { |
| deploymentId: string; |
| apiEndpoint?: string; |
| interactionEndpoint?: string; |
| features?: { |
| basicTracking?: boolean; |
| heatmaps?: boolean; |
| sessionRecording?: boolean; |
| performanceMetrics?: boolean; |
| engagementTracking?: boolean; |
| customEvents?: boolean; |
| }; |
| } |
|
|
| |
| |
| |
| export function generateTrackingScript(options: TrackingScriptOptions): string { |
| const { |
| deploymentId, |
| apiEndpoint = '/api/analytics/track', |
| interactionEndpoint = '/api/analytics/interaction', |
| features = { |
| basicTracking: true, |
| heatmaps: false, |
| sessionRecording: false, |
| performanceMetrics: false, |
| engagementTracking: false, |
| customEvents: false, |
| }, |
| } = options; |
|
|
| return ` |
| <!-- OSW Studio Analytics --> |
| <script> |
| (function() { |
| 'use strict'; |
| |
| // Configuration |
| var config = { |
| deploymentId: '${deploymentId}', |
| apiEndpoint: '${apiEndpoint}', |
| interactionEndpoint: '${interactionEndpoint}', |
| features: ${JSON.stringify(features)} |
| }; |
| |
| // State |
| var pageLoadTime = Date.now(); |
| var scrollMilestones = { 25: false, 50: false, 75: false, 100: false }; |
| var eventQueue = []; |
| var lastFlush = Date.now(); |
| |
| // Generate anonymous session ID from browser fingerprint (no cookies) |
| function generateSessionId() { |
| var canvas = document.createElement('canvas'); |
| var ctx = canvas.getContext('2d'); |
| ctx.textBaseline = 'top'; |
| ctx.font = '14px Arial'; |
| ctx.fillText('osw', 0, 0); |
| var canvasData = canvas.toDataURL(); |
| |
| var fingerprint = [ |
| navigator.userAgent, |
| navigator.language, |
| screen.colorDepth, |
| screen.width + 'x' + screen.height, |
| new Date().getTimezoneOffset(), |
| canvasData.slice(0, 100) |
| ].join('|'); |
| |
| var hash = 0; |
| for (var i = 0; i < fingerprint.length; i++) { |
| var char = fingerprint.charCodeAt(i); |
| hash = ((hash << 5) - hash) + char; |
| hash = hash & hash; |
| } |
| return Math.abs(hash).toString(36); |
| } |
| |
| // Detect device type |
| function getDeviceType() { |
| var width = window.innerWidth; |
| if (width < 768) return 'mobile'; |
| if (width < 1024) return 'tablet'; |
| return 'desktop'; |
| } |
| |
| // Send analytics data |
| // Security: Origin/Referer validation on server (browser-enforced, cannot be spoofed cross-domain) |
| function sendData(endpoint, data) { |
| fetch(endpoint, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(data), |
| keepalive: true |
| }).catch(function() {}); |
| } |
| |
| // Track pageview |
| function trackPageview() { |
| if (!config.features.basicTracking) return; |
| |
| var data = { |
| deploymentId: config.deploymentId, |
| pagePath: window.location.pathname, |
| referrer: document.referrer || '', |
| userAgent: navigator.userAgent, |
| deviceType: getDeviceType() |
| }; |
| |
| sendData(config.apiEndpoint, data); |
| pageLoadTime = Date.now(); |
| scrollMilestones = { 25: false, 50: false, 75: false, 100: false }; |
| } |
| |
| // Track click (for heatmaps) |
| function trackClick(event) { |
| if (!config.features.heatmaps) return; |
| |
| var target = event.target; |
| var selector = target.tagName; |
| if (target.id) selector += '#' + target.id; |
| if (target.className) selector += '.' + target.className.split(' ').join('.'); |
| |
| eventQueue.push({ |
| type: 'click', |
| data: { |
| deploymentId: config.deploymentId, |
| pagePath: window.location.pathname, |
| interactionType: 'click', |
| elementSelector: selector, |
| coordinates: { |
| x: event.clientX, |
| y: event.clientY, |
| scrollY: window.scrollY || window.pageYOffset || 0, |
| viewportWidth: window.innerWidth, |
| viewportHeight: window.innerHeight, |
| documentHeight: Math.max( |
| document.body.scrollHeight, |
| document.body.offsetHeight, |
| document.documentElement.clientHeight, |
| document.documentElement.scrollHeight, |
| document.documentElement.offsetHeight |
| ) |
| }, |
| timeOnPage: Date.now() - pageLoadTime |
| } |
| }); |
| |
| flushEvents(); |
| } |
| |
| // Track scroll depth |
| function trackScroll() { |
| if (!config.features.engagementTracking && !config.features.heatmaps) return; |
| |
| var scrollHeight = document.documentElement.scrollHeight - window.innerHeight; |
| var scrolled = window.scrollY; |
| var percent = scrollHeight > 0 ? Math.round((scrolled / scrollHeight) * 100) : 100; |
| |
| // Track milestones |
| [25, 50, 75, 100].forEach(function(milestone) { |
| if (percent >= milestone && !scrollMilestones[milestone]) { |
| scrollMilestones[milestone] = true; |
| |
| eventQueue.push({ |
| type: 'scroll', |
| data: { |
| deploymentId: config.deploymentId, |
| pagePath: window.location.pathname, |
| interactionType: 'scroll', |
| scrollDepth: milestone, |
| timeOnPage: Date.now() - pageLoadTime |
| } |
| }); |
| } |
| }); |
| |
| flushEvents(); |
| } |
| |
| // Track page exit (send time on page) |
| function trackExit() { |
| if (!config.features.engagementTracking) return; |
| |
| var timeOnPage = Date.now() - pageLoadTime; |
| if (timeOnPage < 1000) return; // Ignore very short visits |
| |
| sendData(config.interactionEndpoint, { |
| deploymentId: config.deploymentId, |
| pagePath: window.location.pathname, |
| interactionType: 'exit', |
| timeOnPage: timeOnPage |
| }); |
| } |
| |
| // Flush event queue (batching) |
| function flushEvents(force) { |
| var now = Date.now(); |
| if (eventQueue.length === 0) return; |
| |
| // Increased thresholds: 30s or 50 events (more efficient batching) |
| if (!force && now - lastFlush < 30000 && eventQueue.length < 50) return; |
| |
| var batch = eventQueue.splice(0, eventQueue.length); |
| |
| // Send as single batched request instead of individual requests |
| var batchData = { |
| batch: true, |
| interactions: batch.map(function(event) { return event.data; }) |
| }; |
| |
| sendData(config.interactionEndpoint, batchData); |
| lastFlush = now; |
| } |
| |
| // Initialize tracking |
| function init() { |
| // Track initial pageview (only once) |
| var pageviewTracked = false; |
| function trackInitialPageview() { |
| if (pageviewTracked) return; |
| pageviewTracked = true; |
| trackPageview(); |
| } |
| |
| if (document.readyState === 'complete') { |
| trackInitialPageview(); |
| } else { |
| window.addEventListener('load', trackInitialPageview, { once: true }); |
| } |
| |
| // Track SPA navigation |
| var pushState = history.pushState; |
| history.pushState = function() { |
| pushState.apply(history, arguments); |
| trackPageview(); |
| }; |
| window.addEventListener('popstate', trackPageview); |
| |
| // Track clicks for heatmaps |
| if (config.features.heatmaps) { |
| document.addEventListener('click', trackClick, true); |
| } |
| |
| // Track scroll |
| if (config.features.engagementTracking || config.features.heatmaps) { |
| var scrollTimer; |
| window.addEventListener('scroll', function() { |
| clearTimeout(scrollTimer); |
| scrollTimer = setTimeout(trackScroll, 100); |
| }); |
| } |
| |
| // Track page exit (with deduplication) |
| var exitTracked = false; |
| function handlePageExit() { |
| flushEvents(true); // Force flush pending events |
| if (config.features.engagementTracking && !exitTracked) { |
| exitTracked = true; |
| trackExit(); |
| } |
| } |
| |
| // Both beforeunload and visibilitychange may fire - deduplicate |
| window.addEventListener('beforeunload', handlePageExit); |
| document.addEventListener('visibilitychange', function() { |
| if (document.visibilityState === 'hidden') { |
| handlePageExit(); |
| } else if (document.visibilityState === 'visible') { |
| // Reset exit tracking when page becomes visible again |
| exitTracked = false; |
| } |
| }); |
| |
| // Flush events periodically (increased to 30s for efficiency) |
| setInterval(function() { flushEvents(false); }, 30000); |
| } |
| |
| init(); |
| })(); |
| </script> |
| `.trim(); |
| } |
|
|