/** * Pulse - Privacy-First Tracking Script * Lightweight, no cookies, GDPR compliant * Includes optional session replay with privacy controls */ (function() { 'use strict'; // * Respect Do Not Track if (navigator.doNotTrack === '1' || navigator.doNotTrack === 'yes' || navigator.msDoNotTrack === '1') { return; } // * Get domain from script tag const script = document.currentScript || document.querySelector('script[data-domain]'); if (!script || !script.getAttribute('data-domain')) { return; } const domain = script.getAttribute('data-domain'); const apiUrl = script.getAttribute('data-api') || 'https://pulse-api.ciphera.net'; // * Performance Monitoring (Core Web Vitals) State let currentEventId = null; let metrics = { lcp: 0, cls: 0, inp: 0 }; let lcpObserved = false; let clsObserved = false; let performanceInsightsEnabled = false; // * Session Replay State let replayEnabled = false; let replayMode = 'disabled'; let replayId = null; let replaySettings = null; let rrwebStopFn = null; let replayEvents = []; let chunkInterval = null; const CHUNK_SIZE = 50; const CHUNK_INTERVAL_MS = 10000; // * Minimal Web Vitals Observer function observeMetrics() { try { if (typeof PerformanceObserver === 'undefined') return; // * LCP (Largest Contentful Paint) - fires when the browser has determined the LCP element (often 2–4s+ after load) new PerformanceObserver((entryList) => { const entries = entryList.getEntries(); const lastEntry = entries[entries.length - 1]; if (lastEntry) { metrics.lcp = lastEntry.startTime; lcpObserved = true; } }).observe({ type: 'largest-contentful-paint', buffered: true }); // * CLS (Cumulative Layout Shift) - accumulates when elements shift after load new PerformanceObserver((entryList) => { for (const entry of entryList.getEntries()) { if (!entry.hadRecentInput) { metrics.cls += entry.value; clsObserved = true; } } }).observe({ type: 'layout-shift', buffered: true }); // * INP (Interaction to Next Paint) - Simplified (track max duration) new PerformanceObserver((entryList) => { const entries = entryList.getEntries(); for (const entry of entries) { // * Track longest interaction if (entry.duration > metrics.inp) metrics.inp = entry.duration; } }).observe({ type: 'event', buffered: true, durationThreshold: 16 }); } catch (e) { // * Browser doesn't support PerformanceObserver or specific entry types } } function sendMetrics() { if (!performanceInsightsEnabled || !currentEventId) return; // * Only include LCP/CLS when the browser actually reported them. Sending 0 overwrites // * the DB before LCP/CLS have fired (they fire late). The backend does partial updates // * and leaves unset fields unchanged. const payload = { event_id: currentEventId, inp: metrics.inp }; if (lcpObserved) payload.lcp = metrics.lcp; if (clsObserved) payload.cls = metrics.cls; const data = JSON.stringify(payload); if (navigator.sendBeacon) { navigator.sendBeacon(apiUrl + '/api/v1/metrics', new Blob([data], {type: 'application/json'})); } else { fetch(apiUrl + '/api/v1/metrics', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: data, keepalive: true }).catch(() => {}); } } // * Start observing metrics immediately (buffered observers will capture early metrics) // * Metrics will only be sent if performance insights are enabled (checked in sendMetrics) observeMetrics(); // * Send metrics when user leaves or hides the page document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { // * Flush replay immediately (page may be torn down soon) if (replayEnabled) { sendReplayChunk(); endReplaySession(); } // * Delay metrics slightly so in-flight LCP/CLS callbacks can run before we send setTimeout(sendMetrics, 150); } }); // * Memory cache for session ID (fallback if sessionStorage is unavailable) let cachedSessionId = null; // * Generate ephemeral session ID (not persistent) function getSessionId() { if (cachedSessionId) { return cachedSessionId; } // * Use a static key for session storage to ensure consistency across pages const key = 'ciphera_session_id'; // * Legacy key support for migration (strip whitespace just in case) const legacyKey = 'plausible_session_' + (domain ? domain.trim() : ''); try { // * Try to get existing session ID cachedSessionId = sessionStorage.getItem(key); // * If not found in new key, try legacy key and migrate if (!cachedSessionId && legacyKey) { cachedSessionId = sessionStorage.getItem(legacyKey); if (cachedSessionId) { sessionStorage.setItem(key, cachedSessionId); sessionStorage.removeItem(legacyKey); } } } catch (e) { // * Access denied or unavailable - ignore } if (!cachedSessionId) { cachedSessionId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); try { sessionStorage.setItem(key, cachedSessionId); } catch (e) { // * Storage full or unavailable - ignore, will use memory cache } } return cachedSessionId; } // * Track pageview function trackPageview() { var routeChangeTime = performance.now(); var isSpaNav = !!currentEventId; if (currentEventId) { // * SPA nav: visibilitychange may not fire, so send previous page's metrics now sendMetrics(); } metrics = { lcp: 0, cls: 0, inp: 0 }; lcpObserved = false; clsObserved = false; currentEventId = null; const path = window.location.pathname + window.location.search; const referrer = document.referrer || ''; const screen = { width: window.innerWidth || screen.width, height: window.innerHeight || screen.height, }; const payload = { domain: domain, path: path, referrer: referrer, screen: screen, session_id: getSessionId(), }; // * Send event fetch(apiUrl + '/api/v1/events', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), keepalive: true, }).then(res => res.json()) .then(data => { if (data && data.id) { currentEventId = data.id; // * For SPA navigations the browser never emits a new largest-contentful-paint // * (LCP is only for full document loads). After the new view has had time to // * paint, we record time-from-route-change as an LCP proxy so /products etc. // * get a value. If the user navigates away before the delay, we leave LCP unset. if (isSpaNav) { var thatId = data.id; setTimeout(function() { if (!lcpObserved && currentEventId === thatId) { metrics.lcp = Math.round(performance.now() - routeChangeTime); lcpObserved = true; } }, 500); } } }).catch(() => { // * Silently fail - don't interrupt user experience }); } // ========================================== // * SESSION REPLAY FUNCTIONALITY // ========================================== // * Fetch replay settings from API async function fetchReplaySettings() { try { const res = await fetch(apiUrl + '/api/v1/replay-settings/' + encodeURIComponent(domain)); if (res.ok) { replaySettings = await res.json(); replayMode = replaySettings.replay_mode; // * Set performance insights enabled flag performanceInsightsEnabled = replaySettings.enable_performance_insights === true; // Check sampling rate if (replayMode !== 'disabled') { const shouldRecord = Math.random() * 100 < replaySettings.replay_sampling_rate; if (!shouldRecord) { replayMode = 'disabled'; return; } } // Auto-start for anonymous_skeleton mode (no consent needed) if (replayMode === 'anonymous_skeleton') { startReplay(true); } } } catch (e) { // Silent fail - replay not critical } } // * Initialize replay session on server async function initReplaySession(isSkeletonMode) { try { const res = await fetch(apiUrl + '/api/v1/replays', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain: domain, session_id: getSessionId(), entry_page: window.location.pathname, is_skeleton_mode: isSkeletonMode, consent_given: !isSkeletonMode, device_type: detectDeviceType(), browser: detectBrowser(), os: detectOS() }) }); if (res.ok) { const data = await res.json(); replayId = data.id; return true; } } catch (e) { // Silent fail } return false; } // * Load rrweb library dynamically function loadRrweb() { return new Promise((resolve, reject) => { if (typeof window.rrweb !== 'undefined') { resolve(); return; } const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/rrweb@2.0.0-alpha.11/dist/rrweb.min.js'; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } // * Start recording session async function startReplay(isSkeletonMode) { if (replayEnabled) return; // Load rrweb if not already loaded try { await loadRrweb(); } catch (e) { console.warn('[Ciphera] Failed to load rrweb library'); return; } if (typeof window.rrweb === 'undefined') return; // Initialize session on server first const initialized = await initReplaySession(isSkeletonMode); if (!initialized) return; replayEnabled = true; // Configure masking based on mode and settings const maskConfig = { // Always mask sensitive inputs maskInputOptions: { password: true, email: true, tel: true, // In skeleton mode, mask all text inputs text: isSkeletonMode, textarea: isSkeletonMode, select: isSkeletonMode }, // Mask all text in skeleton mode maskAllText: isSkeletonMode || (replaySettings && replaySettings.replay_mask_all_text), // Mask all inputs by default (can be overridden in settings) maskAllInputs: replaySettings ? replaySettings.replay_mask_all_inputs : true, // Custom classes for masking maskTextClass: 'ciphera-mask', blockClass: 'ciphera-block', // Mask elements with data-ciphera-mask attribute maskTextSelector: '[data-ciphera-mask]', // Block elements with data-ciphera-block attribute blockSelector: '[data-ciphera-block]', // Custom input masking function for credit cards maskInputFn: (text, element) => { // Mask credit card patterns if (/^\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}$/.test(text)) { return '****-****-****-****'; } // Mask email patterns if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)) { return '***@***.***'; } return text; } }; try { rrwebStopFn = window.rrweb.record({ emit(event) { replayEvents.push(event); // Send chunk when threshold reached if (replayEvents.length >= CHUNK_SIZE) { sendReplayChunk(); } }, ...maskConfig, // Privacy: Don't record external resources recordCanvas: false, collectFonts: false, // Sampling for mouse movement (reduce data) sampling: { mousemove: true, mouseInteraction: true, scroll: 150, input: 'last' }, // Inline styles for replay accuracy inlineStylesheet: true, // Slim snapshot to reduce size slimDOMOptions: { script: true, comment: true, headFavicon: true, headWhitespace: true, headMetaDescKeywords: true, headMetaSocial: true, headMetaRobots: true, headMetaHttpEquiv: true, headMetaAuthorship: true, headMetaVerification: true } }); // Set up periodic chunk sending chunkInterval = setInterval(sendReplayChunk, CHUNK_INTERVAL_MS); } catch (e) { replayEnabled = false; replayId = null; } } // * Redact common PII-like URL query/fragment parameters in replay JSON before sending function redactPiiInReplayJson(jsonStr) { return jsonStr.replace( /([?&])(email|token|session|auth|password|secret|api_key|apikey|access_token|refresh_token)=[^&"'\s]*/gi, '$1$2=***' ); } // * Send chunk of events to server async function sendReplayChunk() { if (!replayId || replayEvents.length === 0) return; const chunk = replayEvents.splice(0, CHUNK_SIZE); const eventsCount = chunk.length; let data = JSON.stringify(chunk); data = redactPiiInReplayJson(data); try { // Try to compress if available let body; let headers = { 'X-Events-Count': eventsCount.toString() }; if (typeof CompressionStream !== 'undefined') { const blob = new Blob([data]); const stream = blob.stream().pipeThrough(new CompressionStream('gzip')); body = await new Response(stream).blob(); headers['Content-Encoding'] = 'gzip'; headers['Content-Type'] = 'application/octet-stream'; } else { body = new Blob([data], { type: 'application/json' }); headers['Content-Type'] = 'application/json'; } await fetch(apiUrl + '/api/v1/replays/' + replayId + '/chunks', { method: 'POST', headers: headers, body: body, keepalive: true }); } catch (e) { // Re-queue events on failure replayEvents.unshift(...chunk); } } // * End replay session function endReplaySession() { if (!replayEnabled || !replayId) return; // Clear interval if (chunkInterval) { clearInterval(chunkInterval); chunkInterval = null; } // Stop recording if (rrwebStopFn) { rrwebStopFn(); rrwebStopFn = null; } // Send remaining events if (replayEvents.length > 0) { const chunk = replayEvents.splice(0); let data = JSON.stringify(chunk); data = redactPiiInReplayJson(data); navigator.sendBeacon( apiUrl + '/api/v1/replays/' + replayId + '/chunks', new Blob([data], { type: 'application/json' }) ); } // Mark session as ended navigator.sendBeacon(apiUrl + '/api/v1/replays/' + replayId + '/end'); replayEnabled = false; replayId = null; } // * Device detection helpers function detectDeviceType() { const ua = navigator.userAgent.toLowerCase(); if (/mobile|android|iphone|ipod/.test(ua)) return 'mobile'; if (/tablet|ipad/.test(ua)) return 'tablet'; return 'desktop'; } function detectBrowser() { const ua = navigator.userAgent.toLowerCase(); if (ua.includes('chrome') && !ua.includes('edg')) return 'Chrome'; if (ua.includes('firefox')) return 'Firefox'; if (ua.includes('safari') && !ua.includes('chrome')) return 'Safari'; if (ua.includes('edg')) return 'Edge'; if (ua.includes('opera')) return 'Opera'; return null; } function detectOS() { const ua = navigator.userAgent.toLowerCase(); if (ua.includes('windows')) return 'Windows'; if (ua.includes('mac os') || ua.includes('macos')) return 'macOS'; if (ua.includes('linux')) return 'Linux'; if (ua.includes('android')) return 'Android'; if (ua.includes('ios') || ua.includes('iphone') || ua.includes('ipad')) return 'iOS'; return null; } // * Public API for replay control (ciphera for backward compat, pulse for Pulse branding) const replayApi = function(cmd) { if (cmd === 'disableReplay') { endReplaySession(); } else if (cmd === 'getReplayMode') { return replayMode; } else if (cmd === 'isReplayEnabled') { return replayEnabled; } }; window.pulse = window.pulse || replayApi; window.ciphera = window.ciphera || replayApi; // * Track initial pageview trackPageview(); // * Fetch replay settings (async, doesn't block pageview) fetchReplaySettings(); // * Track SPA navigation (history API) let lastUrl = location.href; new MutationObserver(() => { const url = location.href; if (url !== lastUrl) { lastUrl = url; trackPageview(); } }).observe(document, { subtree: true, childList: true }); // * Track popstate (browser back/forward) window.addEventListener('popstate', trackPageview); // * Cleanup on page unload window.addEventListener('beforeunload', () => { if (replayEnabled) { sendReplayChunk(); endReplaySession(); } }); })();