Files
pulse/public/script.js

331 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Pulse - Privacy-First Tracking Script
* Lightweight, no cookies, GDPR compliant.
* Default: cross-tab visitor ID (localStorage), optional data-storage-ttl in hours.
* Optional: data-storage="session" for per-tab (ephemeral) counting.
*/
(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';
// * Visitor ID storage: "local" (default, cross-tab) or "session" (ephemeral per-tab)
const storageMode = (script.getAttribute('data-storage') || 'local').toLowerCase() === 'session' ? 'session' : 'local';
// * When storage is "local", optional TTL in hours; after TTL the ID is regenerated (e.g. 24 = one day)
const ttlHours = storageMode === 'local' ? parseFloat(script.getAttribute('data-storage-ttl') || '24') : 0;
const ttlMs = ttlHours > 0 ? ttlHours * 60 * 60 * 1000 : 0;
// * 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;
// * 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 24s+ 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') {
// * Delay metrics slightly so in-flight LCP/CLS callbacks can run before we send
setTimeout(sendMetrics, 150);
}
});
// * Memory cache for session ID (fallback if storage is unavailable)
let cachedSessionId = null;
function generateId() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
// * Returns session/visitor ID. Default: persistent (localStorage, cross-tab), optional TTL in hours.
// * With data-storage="session": ephemeral (sessionStorage, per-tab).
function getSessionId() {
if (cachedSessionId) {
return cachedSessionId;
}
const key = 'ciphera_session_id';
const legacyKey = 'plausible_session_' + (domain ? domain.trim() : '');
if (storageMode === 'local') {
try {
const raw = localStorage.getItem(key);
if (raw) {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.id === 'string') {
const expired = ttlMs > 0 && typeof parsed.created === 'number' && (Date.now() - parsed.created > ttlMs);
if (!expired) {
cachedSessionId = parsed.id;
return cachedSessionId;
}
}
} catch (e) {
// * Invalid JSON or old string format: treat as expired and regenerate
}
}
cachedSessionId = generateId();
// * Race fix: re-read before writing; if another tab wrote in the meantime, use that ID instead
var rawAgain = localStorage.getItem(key);
if (rawAgain) {
try {
var parsedAgain = JSON.parse(rawAgain);
if (parsedAgain && typeof parsedAgain.id === 'string') {
var expiredAgain = ttlMs > 0 && typeof parsedAgain.created === 'number' && (Date.now() - parsedAgain.created > ttlMs);
if (!expiredAgain) {
cachedSessionId = parsedAgain.id;
return cachedSessionId;
}
}
} catch (e2) {}
}
// * Final re-read immediately before write to avoid overwriting a fresher ID from another tab
var rawBeforeWrite = localStorage.getItem(key);
if (rawBeforeWrite) {
try {
var parsedBefore = JSON.parse(rawBeforeWrite);
if (parsedBefore && typeof parsedBefore.id === 'string') {
var expBefore = ttlMs > 0 && typeof parsedBefore.created === 'number' && (Date.now() - parsedBefore.created > ttlMs);
if (!expBefore) {
cachedSessionId = parsedBefore.id;
return cachedSessionId;
}
}
} catch (e3) {}
}
// * Best-effort only: another tab could write before setItem; without locks perfect sync is not achievable
cachedSessionId = generateId();
localStorage.setItem(key, JSON.stringify({ id: cachedSessionId, created: Date.now() }));
} catch (e) {
cachedSessionId = generateId();
}
return cachedSessionId;
}
// * data-storage="session": session storage (ephemeral, per-tab)
try {
cachedSessionId = sessionStorage.getItem(key);
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 = generateId();
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;
// * Run soon so we set lcpObserved before the user leaves; 500ms was too long
// * and we often sent metrics (next nav or visibilitychange+150ms) before it ran.
setTimeout(function() {
if (!lcpObserved && currentEventId === thatId) {
metrics.lcp = Math.round(performance.now() - routeChangeTime);
lcpObserved = true;
}
}, 100);
}
}
}).catch(() => {
// * Silently fail - don't interrupt user experience
});
}
// * Track initial pageview
trackPageview();
// * Track SPA navigation: MutationObserver (DOM updates) and history.pushState/replaceState
// * (some SPAs change the URL without a DOM mutation we observe)
let lastUrl = location.href;
function onUrlChange() {
var url = location.href;
if (url !== lastUrl) {
lastUrl = url;
trackPageview();
}
}
new MutationObserver(onUrlChange).observe(document, { subtree: true, childList: true });
var _push = history.pushState;
var _replace = history.replaceState;
history.pushState = function() { _push.apply(this, arguments); onUrlChange(); };
history.replaceState = function() { _replace.apply(this, arguments); onUrlChange(); };
// * Track popstate (browser back/forward)
window.addEventListener('popstate', trackPageview);
// * Custom events / goals: validate event name (letters, numbers, underscores only; max 64 chars)
var EVENT_NAME_MAX = 64;
var EVENT_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
function trackCustomEvent(eventName) {
if (typeof eventName !== 'string' || !eventName.trim()) return;
var name = eventName.trim().toLowerCase();
if (name.length > EVENT_NAME_MAX || !EVENT_NAME_REGEX.test(name)) {
if (typeof console !== 'undefined' && console.warn) {
console.warn('Pulse: event name must contain only letters, numbers, and underscores (max ' + EVENT_NAME_MAX + ' chars).');
}
return;
}
var path = window.location.pathname + window.location.search;
var referrer = document.referrer || '';
var screenSize = { width: window.innerWidth || 0, height: window.innerHeight || 0 };
var payload = {
domain: domain,
path: path,
referrer: referrer,
screen: screenSize,
session_id: getSessionId(),
name: name,
};
fetch(apiUrl + '/api/v1/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch(function() {});
}
// * Expose pulse.track() for custom events (e.g. pulse.track('signup_click'))
window.pulse = window.pulse || {};
window.pulse.track = trackCustomEvent;
})();