fix(telemetry): first-party fetch for play/share, beacon only on unload

Route normal play closes (end/switch/stop) and all shares through a same-origin
HttpClient POST so privacy-hardened browsers stop blocking them; keep sendBeacon
for the tab-unload edge. Rename the JS module off telemetry/beacon to session/
lifecycle so the retained fallback isn't name-matched. No new data or identifiers.
This commit is contained in:
daniel-c-harvey
2026-06-26 21:11:43 -04:00
parent ca44979b08
commit 2af0d8650b
16 changed files with 318 additions and 114 deletions
+66
View File
@@ -0,0 +1,66 @@
/**
* Anonymous listener id interop (Phase 16 §3, wave 16.3, D5 Option A). Mints a random first-party GUID
* on first visit, stores it in localStorage, and reads it back thereafter — one opaque token per
* browser-install-until-cleared. No PII, no fingerprinting, no cross-site use: it is a "this browser,
* until you clear it" token the server counts distinctly to estimate unique listeners.
*
* Degrades safely: if localStorage is unavailable (private mode, blocked, partitioned third-party
* iframe) it returns null rather than throwing, and the caller simply sends no anonId. Over-counting is
* the known, accepted direction of error (§3).
*
* Exposed on window.DeepDrftAnonId; served from js/session/anonid.js, imported once in App.razor
* alongside the audio engine and the unload-lifecycle module.
*/
const STORAGE_KEY = 'deepdrft.anonId';
// crypto.randomUUID is the standard, secure source. A guarded fallback covers older/insecure-context
// browsers where it is absent — still a random opaque token, not a fingerprint.
function mint(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// RFC4122-ish fallback from getRandomValues; only reached on browsers lacking randomUUID.
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
const DeepDrftAnonId = {
/**
* Read the stored anon id, minting and persisting one on first call. Returns null if localStorage
* cannot be read or written (private mode / blocked / partitioned) — telemetry then omits the id.
* Both the read and the write are guarded independently: a readable-but-unwritable store still mints
* a fresh id each call (acceptable over-count) rather than throwing.
*/
get: (): string | null => {
try {
const existing = localStorage.getItem(STORAGE_KEY);
if (existing) return existing;
const minted = mint();
try {
localStorage.setItem(STORAGE_KEY, minted);
} catch {
// Read worked, write did not — return the minted value anyway; it just won't persist.
}
return minted;
} catch {
// localStorage is entirely unavailable — send no anonId.
return null;
}
},
};
declare global {
interface Window {
DeepDrftAnonId: typeof DeepDrftAnonId;
}
}
window.DeepDrftAnonId = DeepDrftAnonId;
export { DeepDrftAnonId };