Files
pulse/public/script.js
Usman Baig 5677f30f3b feat: add dashboard dimension filtering and custom event properties
Dashboard filtering: FilterBar pills, AddFilterDropdown with dimension/
operator/value steps, URL-serialized filters, all SWR hooks filter-aware.

Custom event properties: pulse.track() accepts props object, EventProperties
panel with auto-discovered key tabs and value bar charts, clickable goal rows.

Updated changelog with both features under v0.13.0-alpha.
2026-03-06 21:02:14 +01:00

463 lines
17 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 hasValidCreated = typeof parsed.created === 'number';
const expired = ttlMs > 0 && (!hasValidCreated || (Date.now() - parsed.created > ttlMs));
if (!expired) {
cachedSessionId = parsed.id;
return cachedSessionId;
}
}
} catch (e) {
// * Invalid JSON: migrate legacy plain-string ID to { id, created } format
if (typeof raw === 'string' && raw.trim().length > 0) {
cachedSessionId = raw.trim();
try {
localStorage.setItem(key, JSON.stringify({ id: cachedSessionId, created: Date.now() }));
} catch (e2) {}
return cachedSessionId;
}
}
}
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 hasValidCreatedAgain = typeof parsedAgain.created === 'number';
var expiredAgain = ttlMs > 0 && (!hasValidCreatedAgain || (Date.now() - parsedAgain.created > ttlMs));
if (!expiredAgain) {
cachedSessionId = parsedAgain.id;
return cachedSessionId;
}
}
} catch (e2) {
if (typeof rawAgain === 'string' && rawAgain.trim().length > 0) {
cachedSessionId = rawAgain.trim();
return cachedSessionId;
}
}
}
// * 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 hasValidCreatedBefore = typeof parsedBefore.created === 'number';
var expBefore = ttlMs > 0 && (!hasValidCreatedBefore || (Date.now() - parsedBefore.created > ttlMs));
if (!expBefore) {
cachedSessionId = parsedBefore.id;
return cachedSessionId;
}
}
} catch (e3) {
if (typeof rawBeforeWrite === 'string' && rawBeforeWrite.trim().length > 0) {
cachedSessionId = rawBeforeWrite.trim();
return cachedSessionId;
}
}
}
// * Best-effort only: another tab could write between here and setItem; without locks perfect sync is not achievable
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();
// * Check for 404 after SPA navigation (deferred so title updates first)
setTimeout(check404, 100);
// * Reset scroll depth tracking for the new page
if (trackScroll) scrollFired = {};
}
}
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', function() {
trackPageview();
setTimeout(check404, 100);
if (trackScroll) scrollFired = {};
});
// * 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, props) {
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,
};
// * Attach custom properties if provided (max 30 props, key max 200 chars, value max 2000 chars)
if (props && typeof props === 'object' && !Array.isArray(props)) {
var sanitized = {};
var count = 0;
for (var key in props) {
if (!props.hasOwnProperty(key)) continue;
if (count >= 30) break;
var k = String(key).substring(0, 200);
var v = String(props[key]).substring(0, 2000);
sanitized[k] = v;
count++;
}
if (count > 0) payload.props = sanitized;
}
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;
// * Auto-track 404 error pages (on by default)
// * Detects pages where document.title contains "404" or "not found"
// * Opt-out: add data-no-404 to the script tag
var track404 = !script.hasAttribute('data-no-404');
var sent404ForUrl = '';
function check404() {
if (!track404) return;
// * Only fire once per URL
var currentUrl = location.href;
if (sent404ForUrl === currentUrl) return;
if (/404|not found/i.test(document.title)) {
sent404ForUrl = currentUrl;
trackCustomEvent('404');
}
}
// * Check on initial load (deferred so SPAs can set title)
setTimeout(check404, 0);
// * Auto-track scroll depth at 25%, 50%, 75%, and 100% (on by default)
// * Each threshold fires once per pageview; resets on SPA navigation
// * Opt-out: add data-no-scroll to the script tag
var trackScroll = !script.hasAttribute('data-no-scroll');
if (trackScroll) {
var scrollThresholds = [25, 50, 75, 100];
var scrollFired = {};
var scrollTicking = false;
function checkScroll() {
var docHeight = document.documentElement.scrollHeight;
var viewHeight = window.innerHeight;
// * Page fits in viewport — nothing to scroll
if (docHeight <= viewHeight) return;
var scrollTop = window.scrollY;
var scrollPercent = Math.round((scrollTop + viewHeight) / docHeight * 100);
for (var i = 0; i < scrollThresholds.length; i++) {
var t = scrollThresholds[i];
if (!scrollFired[t] && scrollPercent >= t) {
scrollFired[t] = true;
trackCustomEvent('scroll_' + t);
}
}
scrollTicking = false;
}
window.addEventListener('scroll', function() {
if (!scrollTicking) {
scrollTicking = true;
requestAnimationFrame(checkScroll);
}
}, { passive: true });
}
// * Auto-track outbound link clicks and file downloads (on by default)
// * Opt-out: add data-no-outbound or data-no-downloads to the script tag
var trackOutbound = !script.hasAttribute('data-no-outbound');
var trackDownloads = !script.hasAttribute('data-no-downloads');
if (trackOutbound || trackDownloads) {
var FILE_EXT_REGEX = /\.(pdf|zip|gz|tar|xlsx|xls|csv|docx|doc|pptx|ppt|mp4|mp3|wav|avi|mov|exe|dmg|pkg|deb|rpm|iso|7z|rar)($|\?|#)/i;
document.addEventListener('click', function(e) {
var el = e.target;
// * Walk up from clicked element to find nearest <a> tag
while (el && el.tagName !== 'A') el = el.parentElement;
if (!el || !el.href) return;
try {
var url = new URL(el.href, location.href);
// * Skip non-http links (mailto:, tel:, javascript:, etc.)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
// * Check file download first (download attribute or known file extension)
if (trackDownloads && (el.hasAttribute('download') || FILE_EXT_REGEX.test(url.pathname))) {
trackCustomEvent('file_download');
return;
}
// * Check outbound link (different hostname)
if (trackOutbound && url.hostname && url.hostname !== location.hostname) {
trackCustomEvent('outbound_link');
}
} catch (err) {
// * Invalid URL - skip silently
}
}, true); // * Capture phase: fires before default navigation
}
})();