329 lines
12 KiB
JavaScript
329 lines
12 KiB
JavaScript
/**
|
||
* 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', 10) : 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 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') {
|
||
// * 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) {}
|
||
}
|
||
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;
|
||
|
||
})();
|