Files
pulse/public/script.js

538 lines
16 KiB
JavaScript

/**
* 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 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)
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
if (lastEntry) {
metrics.lcp = lastEntry.startTime;
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
// * CLS (Cumulative Layout Shift)
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
metrics.cls += entry.value;
}
}
}).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() {
// * Only send metrics if performance insights are enabled
if (!performanceInsightsEnabled || !currentEventId || (metrics.lcp === 0 && metrics.cls === 0 && metrics.inp === 0)) return;
// * Use sendBeacon if available for reliability on unload
const data = JSON.stringify({
event_id: currentEventId,
lcp: metrics.lcp,
cls: metrics.cls,
inp: metrics.inp
});
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') {
sendMetrics();
// Also flush replay data
if (replayEnabled) {
sendReplayChunk();
endReplaySession();
}
}
});
// * 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() {
// * Reset metrics for new pageview (SPA navigation)
// * We don't reset immediately on the first run, but for subsequent calls we should
// * However, for the very first call, metrics are already 0.
// * The issue is if we reset metrics here, we might lose early captured metrics (e.g. LCP) if this runs late?
// * No, trackPageview runs early.
// * BUT for SPA navigation, we want to reset.
if (currentEventId) {
// If we already had an event ID, it means this is a subsequent navigation
// We should try to send metrics for the *previous* page before resetting?
// Ideally visibilitychange handles this, but for SPA nav it might not trigger visibilitychange.
sendMetrics();
}
metrics = { lcp: 0, cls: 0, inp: 0 };
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;
}
}).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();
}
});
})();