Merge pull request #21 from ciphera-net/staging
[PULSE-51] Visitor ID storage: optional localStorage for cross-tab unique visitors
This commit is contained in:
21
CHANGELOG.md
21
CHANGELOG.md
@@ -4,18 +4,23 @@ All notable changes to Pulse (frontend and product) are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- No unreleased changes yet; add items here as you work toward the next release.
|
||||
|
||||
## [0.1.0] - 2026-02-09
|
||||
## [0.2.0-alpha] - 2026-02-11
|
||||
|
||||
### Added
|
||||
|
||||
- Initial changelog and release process (PULSE-28).
|
||||
- **Smarter unique visitor counts.** If someone opens your site in several tabs or windows, they’re now counted as one visitor by default, so your stats better reflect real people.
|
||||
- **Control over how visitors are counted.** You can switch back to “one visitor per tab” (more private, no lasting identifier) by adding an option to your script embed. The dashboard shows the right snippet for both options.
|
||||
- **Optional expiry for the visitor ID.** You can set how long the cross-tab visitor ID is kept (e.g. 24 hours); after that it’s refreshed automatically.
|
||||
|
||||
## [0.1.0-alpha] - 2026-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- Initial changelog and release process.
|
||||
- Release documentation in `docs/releasing.md` and optional changelog check script.
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.1.0...HEAD
|
||||
[0.1.0]: https://github.com/ciphera-net/pulse/releases/tag/v0.1.0
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.2.0-alpha...HEAD
|
||||
[0.2.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.1.0-alpha...v0.2.0-alpha
|
||||
[0.1.0-alpha]: https://github.com/ciphera-net/pulse/releases/tag/v0.1.0-alpha
|
||||
|
||||
@@ -89,6 +89,9 @@ export default function ScriptSetupBlock({
|
||||
<code className="text-xs text-neutral-900 dark:text-white break-all font-mono block pr-10">
|
||||
{`<script defer data-domain="${site.domain}" data-api="${API_URL}" src="${APP_URL}/script.js"></script>`}
|
||||
</code>
|
||||
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Default: cross-tab (localStorage). Optional: <code className="rounded px-1 bg-neutral-200 dark:bg-neutral-700">data-storage="session"</code> to opt out (per-tab, ephemeral). Optional: <code className="rounded px-1 bg-neutral-200 dark:bg-neutral-700">data-storage-ttl="48"</code> to set expiry in hours (default: 24).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyScript}
|
||||
|
||||
102
public/script.js
102
public/script.js
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Pulse - Privacy-First Tracking Script
|
||||
* Lightweight, no cookies, GDPR compliant
|
||||
* 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() {
|
||||
@@ -19,6 +21,11 @@
|
||||
|
||||
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;
|
||||
@@ -102,25 +109,100 @@
|
||||
}
|
||||
});
|
||||
|
||||
// * Memory cache for session ID (fallback if sessionStorage is unavailable)
|
||||
// * Memory cache for session ID (fallback if storage is unavailable)
|
||||
let cachedSessionId = null;
|
||||
|
||||
// * Generate ephemeral session ID (not persistent)
|
||||
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;
|
||||
}
|
||||
|
||||
// * 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 (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;
|
||||
}
|
||||
|
||||
// * If not found in new key, try legacy key and migrate
|
||||
// * data-storage="session": session storage (ephemeral, per-tab)
|
||||
try {
|
||||
cachedSessionId = sessionStorage.getItem(key);
|
||||
if (!cachedSessionId && legacyKey) {
|
||||
cachedSessionId = sessionStorage.getItem(legacyKey);
|
||||
if (cachedSessionId) {
|
||||
@@ -133,7 +215,7 @@
|
||||
}
|
||||
|
||||
if (!cachedSessionId) {
|
||||
cachedSessionId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
cachedSessionId = generateId();
|
||||
try {
|
||||
sessionStorage.setItem(key, cachedSessionId);
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user